Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -4,7 +4,7 @@ before:
|
||||
- go mod tidy
|
||||
builds:
|
||||
-
|
||||
id: ntfy
|
||||
id: ntfy_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -16,6 +16,20 @@ builds:
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_armv6
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
||||
ldflags:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [6]
|
||||
# No "upx", since it causes random core dumps, see
|
||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_armv7
|
||||
binary: ntfy
|
||||
@@ -28,9 +42,8 @@ builds:
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [7]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
# No "upx", since it causes random core dumps, see
|
||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_arm64
|
||||
binary: ntfy
|
||||
@@ -42,9 +55,8 @@ builds:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm64]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
# No "upx", since it causes random core dumps, see
|
||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
nfpms:
|
||||
-
|
||||
package_name: ntfy
|
||||
@@ -59,12 +71,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
|
||||
|
||||
@@ -2,4 +2,6 @@ FROM alpine
|
||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
|
||||
COPY ntfy /usr/bin
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
203
Makefile
@@ -3,55 +3,90 @@ 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 server-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||
@echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make docs - Build the documentation"
|
||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||
@echo
|
||||
@echo "Build everything:"
|
||||
@echo " make build - Build web app, documentation and server/client"
|
||||
@echo " make clean - Clean build/dist folders"
|
||||
@echo
|
||||
@echo "Build server & client (not release version):"
|
||||
@echo " make server - Build server & client (all architectures)"
|
||||
@echo " make server-amd64 - Build server & client (amd64 only)"
|
||||
@echo " make server-armv6 - Build server & client (armv6 only)"
|
||||
@echo " make server-armv7 - Build server & client (armv7 only)"
|
||||
@echo " make server-arm64 - Build server & client (arm64 only)"
|
||||
@echo
|
||||
@echo "Build web app:"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo
|
||||
@echo "Build documentation:"
|
||||
@echo " make docs - Build the documentation"
|
||||
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "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-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
||||
@echo " make install-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
||||
@echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs server
|
||||
|
||||
|
||||
# Documentation
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-deps: .PHONY
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
docs: docs-deps
|
||||
docs-build: .PHONY
|
||||
mkdocs build
|
||||
|
||||
|
||||
# Web app
|
||||
|
||||
web: web-deps web-build
|
||||
|
||||
web-deps:
|
||||
cd web \
|
||||
&& npm install \
|
||||
&& node_modules/svgo/bin/svgo src/img/*.svg
|
||||
cd web && npm install
|
||||
# If this fails for .svg files, optimizes them with svgo
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
@@ -63,7 +98,40 @@ web-build:
|
||||
../server/site/config.js \
|
||||
../server/site/asset-manifest.json
|
||||
|
||||
web: web-deps web-build
|
||||
|
||||
# Main server/client build
|
||||
|
||||
server: server-deps
|
||||
goreleaser build --snapshot --rm-dist --debug
|
||||
|
||||
server-amd64: server-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
|
||||
|
||||
server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6
|
||||
|
||||
server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
|
||||
|
||||
server-arm64: server-deps-static-sites server-deps-gcc-arm64
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
|
||||
|
||||
server-deps: server-deps-static-sites server-deps-all server-deps-gcc
|
||||
|
||||
server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64
|
||||
|
||||
server-deps-static-sites:
|
||||
mkdir -p server/docs server/site
|
||||
touch server/docs/index.html server/site/app.html
|
||||
|
||||
server-deps-all:
|
||||
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||
|
||||
server-deps-gcc-armv6-armv7:
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||
|
||||
server-deps-gcc-arm64:
|
||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||
|
||||
|
||||
# Test/check targets
|
||||
@@ -114,56 +182,55 @@ 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: clean server-deps release-check-tags docs web check
|
||||
goreleaser release --rm-dist --debug
|
||||
|
||||
release-snapshot: clean server-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
||||
|
||||
release-check-tags:
|
||||
$(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
|
||||
|
||||
|
||||
# Installing targets
|
||||
|
||||
install:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
|
||||
install-amd64: remove-binary
|
||||
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||
|
||||
install-deb:
|
||||
install-armv6: remove-binary
|
||||
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||
|
||||
install-armv7: remove-binary
|
||||
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||
|
||||
install-arm64: remove-binary
|
||||
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||
|
||||
remove-binary:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
|
||||
install-amd64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
install-armv6-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
||||
|
||||
install-armv7-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||
|
||||
install-arm64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||
|
||||
purge-package:
|
||||
sudo systemctl stop ntfy || true
|
||||
sudo apt-get purge ntfy || true
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
11
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)
|
||||
@@ -34,7 +34,14 @@ too.
|
||||
[Building](https://ntfy.sh/docs/develop/)
|
||||
|
||||
## Contributing
|
||||
I welcome any and all contributions. Just create a PR or an issue.
|
||||
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
|
||||
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
|
||||
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
||||
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
## Contact me
|
||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,9 +30,6 @@ 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,
|
||||
@@ -46,12 +43,6 @@ func New() *cli.App {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -26,6 +26,7 @@ var cmdPublish = &cli.Command{
|
||||
&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"},
|
||||
@@ -72,6 +73,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 +114,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))
|
||||
}
|
||||
|
||||
16
cmd/user.go
@@ -25,7 +25,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 +45,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 +57,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 +72,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 +93,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.
|
||||
|
||||
@@ -275,14 +270,3 @@ func userCommandFlags() []cli.Flag {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
@@ -490,6 +528,11 @@ or the root domain:
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# WebSockets support
|
||||
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
|
||||
@@ -514,6 +557,11 @@ or the root domain:
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# WebSockets support
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
301
docs/develop.md
@@ -1,51 +1,287 @@
|
||||
# Building
|
||||
# Development
|
||||
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||
|
||||
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||
|
||||
## ntfy server
|
||||
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 server-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||
make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
||||
make web - Build the web app
|
||||
make docs - Build the documentation
|
||||
make check - Run all tests, vetting/formatting checks and linters
|
||||
...
|
||||
```
|
||||
|
||||
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64),
|
||||
you can simply run `make build`:
|
||||
|
||||
``` shell
|
||||
$ make build
|
||||
...
|
||||
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
|
||||
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
|
||||
```
|
||||
|
||||
You'll see all the outputs in the `dist/` folder afterwards:
|
||||
|
||||
``` bash
|
||||
$ find dist
|
||||
dist
|
||||
dist/metadata.json
|
||||
dist/ntfy_arm64_linux_arm64
|
||||
dist/ntfy_arm64_linux_arm64/ntfy
|
||||
dist/ntfy_armv7_linux_arm_7
|
||||
dist/ntfy_armv7_linux_arm_7/ntfy
|
||||
dist/ntfy_amd64_linux_amd64
|
||||
dist/ntfy_amd64_linux_amd64/ntfy
|
||||
dist/config.yaml
|
||||
dist/artifacts.json
|
||||
```
|
||||
|
||||
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
|
||||
use the `make release-snapshot` target:
|
||||
|
||||
``` shell
|
||||
$ make release-snapshot
|
||||
...
|
||||
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
|
||||
```
|
||||
|
||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||
|
||||
### Build the ntfy binary
|
||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
|
||||
|
||||
``` shell
|
||||
$ make
|
||||
Build server & client (not release version):
|
||||
make server - Build server & client (all architectures)
|
||||
make server-amd64 - Build server & client (amd64 only)
|
||||
make server-armv7 - Build server & client (armv7 only)
|
||||
make server-arm64 - Build server & client (arm64 only)
|
||||
```
|
||||
|
||||
So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern
|
||||
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary
|
||||
right away:
|
||||
|
||||
``` shell
|
||||
$ make server-amd64 install-amd64
|
||||
$ ntfy serve
|
||||
```
|
||||
|
||||
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
||||
`make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||
|
||||
``` shell
|
||||
$ export CGO_ENABLED=1
|
||||
$ make server-deps-static-sites
|
||||
$ go run main.go serve
|
||||
2022/03/18 08:43:55 Listening on :2586[http]
|
||||
...
|
||||
```
|
||||
|
||||
If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
||||
```
|
||||
$ go run main.go serve
|
||||
server/server.go:85:13: pattern docs: no matching files found
|
||||
```
|
||||
|
||||
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
|
||||
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites`
|
||||
target creates dummy files that ensures that you'll be able to build.
|
||||
|
||||
|
||||
### Build the web app
|
||||
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
||||
is really simple. Just type `make web` and you're in business:
|
||||
|
||||
``` shell
|
||||
$ make web
|
||||
...
|
||||
```
|
||||
|
||||
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
|
||||
that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary.
|
||||
|
||||
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
|
||||
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
|
||||
will automatically refresh:
|
||||
|
||||
``` shell
|
||||
$ cd web
|
||||
$ npm start
|
||||
```
|
||||
|
||||
### Build the docs
|
||||
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
|
||||
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
|
||||
|
||||
``` shell
|
||||
$ make docs
|
||||
...
|
||||
```
|
||||
|
||||
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
|
||||
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
|
||||
|
||||
```
|
||||
$ mkdocs serve
|
||||
INFO - Building documentation...
|
||||
INFO - Cleaning site directory
|
||||
INFO - Documentation built in 5.53 seconds
|
||||
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
|
||||
|
||||
## Android app
|
||||
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)
|
||||
### Build F-Droid flavor (no FCM)
|
||||
!!! info
|
||||
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||
|
||||
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||
if you're self-hosting the server. Then run:
|
||||
```
|
||||
@@ -56,8 +292,13 @@ 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)
|
||||
|
||||
273
docs/examples.md
@@ -16,6 +16,27 @@ rsync -a root@laptop /backups/laptop \
|
||||
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -93,3 +114,255 @@ Or, if you only want to send notifications using shoutrrr:
|
||||
```
|
||||
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||
```
|
||||
|
||||
## Random cronjobs
|
||||
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||
|
||||
``` cron
|
||||
# Check github/ntfy user
|
||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||
~
|
||||
```
|
||||
|
||||
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
|
||||
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||
|
||||
## Node-RED
|
||||
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Example: Send a message (click to expand)</summary>
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
"id": "c956e688cc74ad8e",
|
||||
"type": "http request",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "ntfy.sh",
|
||||
"method": "POST",
|
||||
"ret": "txt",
|
||||
"paytoqs": "ignore",
|
||||
"url": "https://ntfy.sh/mytopic",
|
||||
"tls": "",
|
||||
"persist": false,
|
||||
"proxy": "",
|
||||
"authType": "",
|
||||
"senderr": false,
|
||||
"credentials":
|
||||
{
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"x": 590,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "32ee1eade51fae50",
|
||||
"type": "function",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "data",
|
||||
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 470,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"c956e688cc74ad8e"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b287e59cd2311815",
|
||||
"type": "inject",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "Manual start",
|
||||
"props":
|
||||
[
|
||||
{
|
||||
"p": "payload"
|
||||
},
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "20",
|
||||
"topic": "",
|
||||
"payload": "",
|
||||
"payloadType": "date",
|
||||
"x": 330,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"32ee1eade51fae50"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||

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

|
||||
|
||||
## Gatus service health check
|
||||
|
||||
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
||||
```
|
||||
alerting:
|
||||
custom:
|
||||
url: "https://ntfy.sh"
|
||||
method: "POST"
|
||||
body: |
|
||||
{
|
||||
"topic": "mytopic",
|
||||
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
|
||||
"title": "Gatus",
|
||||
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
|
||||
"priority": 3
|
||||
}
|
||||
default-alert:
|
||||
enabled: true
|
||||
description: "health check failed"
|
||||
send-on-resolved: true
|
||||
failure-threshold: 3
|
||||
success-threshold: 3
|
||||
placeholders:
|
||||
ALERT_TRIGGERED_OR_RESOLVED:
|
||||
TRIGGERED: "warning"
|
||||
RESOLVED: "white_check_mark"
|
||||
```
|
||||
|
||||
@@ -33,10 +33,11 @@ If you do not care for Firebase, I suggest you install the [F-Droid version](htt
|
||||
of the app and [self-host your own ntfy server](install.md).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -26,23 +26,38 @@ 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.21.1/ntfy_1.21.1_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.21.1_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.21.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.1/ntfy_1.21.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.21.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.21.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.1_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.21.1/ntfy_1.21.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.21.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.21.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.1_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.21.1/ntfy_1.21.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.21.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.21.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.1_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.21.1/ntfy_1.21.1_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.21.1/ntfy_1.21.1_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.21.1/ntfy_1.21.1_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.21.1/ntfy_1.21.1_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.21.1/ntfy_1.21.1_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.1/ntfy_1.21.1_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.21.1/ntfy_1.21.1_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.21.1/ntfy_1.21.1_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -146,7 +176,6 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -181,6 +210,24 @@ docker run \
|
||||
serve
|
||||
```
|
||||
|
||||
Using docker-compose:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: ntfy
|
||||
command:
|
||||
- serve
|
||||
volumes:
|
||||
- /var/cache/ntfy:/var/cache/ntfy
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 80:80
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||
```
|
||||
FROM binwiederhier/ntfy
|
||||
@@ -188,13 +235,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.
|
||||
|
||||
1415
docs/publish.md
200
docs/releases.md
@@ -2,8 +2,206 @@
|
||||
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.12.0 (UNRELEASED)
|
||||
|
||||
**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:**
|
||||
|
||||
* 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.1
|
||||
Released Apr 24, 2022
|
||||
|
||||
**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-sidebar {
|
||||
width: 12.5rem !important;
|
||||
}
|
||||
|
||||
.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 |
|
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 |
@@ -31,7 +31,7 @@ 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
|
||||
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,11 +42,19 @@ 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>
|
||||
|
||||
@@ -80,6 +88,34 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
|
||||
## Share to topic
|
||||
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
||||
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
||||
you shared content to and lists them at the bottom.
|
||||
|
||||
The feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures:
|
||||
|
||||
<div id="share-to-topic-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-share-1.jpg"><img src="../../static/img/android-screenshot-share-1.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-share-2.jpg"><img src="../../static/img/android-screenshot-share-2.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## ntfy:// links
|
||||
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
||||
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
||||
or to simply directly link to a topic from a mobile website.
|
||||
|
||||
!!! info
|
||||
Android deep linking of http/https links is very brittle and limited, which is why something like `https://<host>/<topic>/subscribe` is
|
||||
**not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20).
|
||||
|
||||
**Supported link formats:**
|
||||
|
||||
| Link format | Example | Description |
|
||||
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Integrations
|
||||
|
||||
### UnifiedPush
|
||||
@@ -130,19 +166,21 @@ notification popups:
|
||||
|
||||
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
||||
|
||||
| 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,14 +202,14 @@ Here's what that looks like:
|
||||
|
||||
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
||||
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
46
go.mod
@@ -4,48 +4,50 @@ go 1.17
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||
cloud.google.com/go/storage v1.19.0 // indirect
|
||||
cloud.google.com/go/storage v1.22.0 // indirect
|
||||
firebase.google.com/go v3.13.0+incompatible
|
||||
github.com/BurntSushi/toml v1.0.0 // indirect
|
||||
github.com/BurntSushi/toml v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // 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.12
|
||||
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
|
||||
github.com/urfave/cli/v2 v2.4.7
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // 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
|
||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
||||
google.golang.org/api v0.75.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
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.101.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/googleapis/gax-go/v2 v2.3.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
|
||||
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-20220421235706-1d1ef9303861 // indirect
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/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/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect
|
||||
google.golang.org/grpc v1.46.0 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
)
|
||||
|
||||
125
go.sum
@@ -26,9 +26,9 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
|
||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go v0.100.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.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
|
||||
cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
@@ -36,14 +36,17 @@ 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 +56,17 @@ 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.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
|
||||
cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||
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,8 +84,8 @@ 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -100,6 +104,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=
|
||||
@@ -161,8 +166,9 @@ github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPg
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
|
||||
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
@@ -182,10 +188,14 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
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 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
|
||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/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 +209,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.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -211,10 +221,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 +231,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.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.4.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo=
|
||||
github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -243,8 +251,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-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -316,8 +325,13 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-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-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8=
|
||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -334,8 +348,11 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-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 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -398,12 +415,17 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-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-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 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-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
|
||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.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 +439,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-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 +495,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-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 h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
@@ -507,11 +530,12 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
|
||||
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
||||
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
||||
google.golang.org/api v0.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.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
|
||||
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
|
||||
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
|
||||
google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
|
||||
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -559,6 +583,7 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
|
||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-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=
|
||||
@@ -582,15 +607,20 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
|
||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-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-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
||||
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
|
||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -617,8 +647,10 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
|
||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||
google.golang.org/grpc v1.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 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
|
||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
@@ -632,8 +664,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.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=
|
||||
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
@@ -29,6 +30,7 @@ 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,
|
||||
@@ -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, attachment_owner, 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, attachment_owner, 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, attachment_owner, 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, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, 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, attachment_owner, 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, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
@@ -88,7 +90,7 @@ const (
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 5
|
||||
currentSchemaVersion = 6
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -166,6 +168,11 @@ const (
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
COMMIT;
|
||||
`
|
||||
|
||||
// 5 -> 6
|
||||
migrate5To6AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
)
|
||||
|
||||
type messageCache struct {
|
||||
@@ -228,6 +235,14 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||
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,
|
||||
m.ID,
|
||||
@@ -238,6 +253,7 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||
m.Priority,
|
||||
tags,
|
||||
m.Click,
|
||||
actionsStr,
|
||||
attachmentName,
|
||||
attachmentType,
|
||||
attachmentSize,
|
||||
@@ -355,7 +371,7 @@ func (c *messageCache) Prune(olderThan time.Time) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *messageCache) AttachmentsSize(owner string) (int64, error) {
|
||||
func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -399,7 +415,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, attachmentOwner, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
×tamp,
|
||||
@@ -409,6 +425,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
&priority,
|
||||
&tagsStr,
|
||||
&click,
|
||||
&actionsStr,
|
||||
&attachmentName,
|
||||
&attachmentType,
|
||||
&attachmentSize,
|
||||
@@ -424,6 +441,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{
|
||||
@@ -445,6 +468,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: click,
|
||||
Actions: actions,
|
||||
Attachment: att,
|
||||
Encoding: encoding,
|
||||
})
|
||||
@@ -490,6 +514,8 @@ func setupCacheDB(db *sql.DB) error {
|
||||
return migrateFrom3(db)
|
||||
} else if schemaVersion == 4 {
|
||||
return migrateFrom4(db)
|
||||
} else if schemaVersion == 5 {
|
||||
return migrateFrom5(db)
|
||||
}
|
||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||
}
|
||||
@@ -562,5 +588,16 @@ func migrateFrom4(db *sql.DB) error {
|
||||
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateFrom5(db)
|
||||
}
|
||||
|
||||
func migrateFrom5(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 5 to 6")
|
||||
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // Update this when a new version is added
|
||||
}
|
||||
|
||||
@@ -337,11 +337,11 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
||||
|
||||
size, err := c.AttachmentsSize("1.2.3.4")
|
||||
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)
|
||||
|
||||
|
||||
141
server/server.go
@@ -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})?$`)
|
||||
@@ -269,6 +270,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.handleEmpty(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.handleWebConfig(w, r)
|
||||
} 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)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
@@ -277,6 +280,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.handleFile)(w, r, v)
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.handleOptions(w, r)
|
||||
} 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,7 +296,7 @@ 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)) {
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.handleTopic(w, r)
|
||||
}
|
||||
return errHTTPNotFound
|
||||
@@ -349,6 +354,19 @@ var config = {
|
||||
return err
|
||||
}
|
||||
|
||||
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) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
@@ -378,6 +396,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 +411,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
|
||||
}
|
||||
@@ -516,6 +535,13 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
actionsStr := readParam(r, "x-actions", "actions", "action")
|
||||
if actionsStr != "" {
|
||||
m.Actions, err = parseActions(actionsStr)
|
||||
if err != nil {
|
||||
return false, false, "", false, err // wrapped errHTTPBadRequestActionsInvalid
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
if unifiedpush {
|
||||
firebase = false
|
||||
@@ -536,35 +562,35 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
// 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 > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, unifiedpush bool) error {
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
||||
if unifiedpush {
|
||||
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
|
||||
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 2
|
||||
} 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) {
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 4
|
||||
}
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
|
||||
}
|
||||
|
||||
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) 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,22 +598,21 @@ 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 {
|
||||
@@ -596,7 +621,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
var ext string
|
||||
m.Attachment.Owner = 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 +629,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
|
||||
}
|
||||
@@ -1093,6 +1118,62 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1164,7 +1245,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -81,6 +81,13 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
||||
"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
|
||||
|
||||
@@ -203,6 +203,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))
|
||||
|
||||
@@ -706,6 +714,12 @@ func (t *testMailer) Send(from, to string, m *message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testMailer) Count() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.count
|
||||
}
|
||||
|
||||
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
for i := 0; i < 60; i++ {
|
||||
@@ -862,6 +876,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.mailer = mailer
|
||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
require.Equal(t, "A message", m.Message)
|
||||
require.Equal(t, 1, mailer.Count())
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_WithActions(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
body := `{
|
||||
"topic":"mytopic",
|
||||
"message":"A message",
|
||||
"actions": [
|
||||
{
|
||||
"action": "view",
|
||||
"label": "Open portal",
|
||||
"url": "https://home.nest.com/"
|
||||
},
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
}
|
||||
]
|
||||
}`
|
||||
response := request(t, s, "POST", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
require.Equal(t, "A message", m.Message)
|
||||
require.Equal(t, 2, len(m.Actions))
|
||||
require.Equal(t, "view", m.Actions[0].Action)
|
||||
require.Equal(t, "Open portal", m.Actions[0].Label)
|
||||
require.Equal(t, "https://home.nest.com/", m.Actions[0].URL)
|
||||
require.Equal(t, "http", m.Actions[1].Action)
|
||||
require.Equal(t, "Turn down", m.Actions[1].Label)
|
||||
require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL)
|
||||
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
body := `{"topic":"mytopic",INVALID`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachment(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
@@ -870,7 +981,7 @@ 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.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
@@ -882,7 +993,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)
|
||||
}
|
||||
@@ -911,7 +1022,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)
|
||||
}
|
||||
@@ -931,7 +1042,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
||||
require.Equal(t, "", msg.Attachment.Owner)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -968,9 +1079,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 +1091,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 +1123,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 +1199,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 {
|
||||
|
||||
@@ -27,6 +27,7 @@ type message struct {
|
||||
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"`
|
||||
@@ -42,6 +43,34 @@ type attachment struct {
|
||||
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
|
||||
}
|
||||
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Actions []action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *message) (string, error)
|
||||
|
||||
|
||||
107
server/util.go
@@ -1,10 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
actionIDLength = 10
|
||||
actionsMax = 3
|
||||
)
|
||||
|
||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
value := strings.ToLower(readParam(r, names...))
|
||||
if value == "" {
|
||||
@@ -40,3 +47,103 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "only %d actions allowed", actionsMax)
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action '%s' unknown", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'label' is required")
|
||||
} else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL == "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if action.Action == "http" && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
}
|
||||
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
rawActions := util.SplitNoEmpty(s, ";")
|
||||
for _, rawAction := range rawActions {
|
||||
newAction := &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
parts := util.SplitNoEmpty(rawAction, ",")
|
||||
if len(parts) < 3 {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
|
||||
}
|
||||
for i, part := range parts {
|
||||
key, value := util.SplitKV(part, "=")
|
||||
if key == "" && i == 0 {
|
||||
newAction.Action = value
|
||||
} else if key == "" && i == 1 {
|
||||
newAction.Label = value
|
||||
} else if key == "" && util.InStringList([]string{"view", "http"}, newAction.Action) && i == 2 {
|
||||
newAction.URL = value
|
||||
} else 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 if key != "" {
|
||||
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 nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", 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 nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
|
||||
}
|
||||
} else {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "unknown term '%s'", part)
|
||||
}
|
||||
}
|
||||
actions = append(actions, newAction)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
@@ -27,3 +27,56 @@ func TestReadBoolParam(t *testing.T) {
|
||||
require.Equal(t, false, up)
|
||||
require.Equal(t, true, firebase)
|
||||
}
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -31,9 +32,17 @@ type visitor struct {
|
||||
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),
|
||||
@@ -91,3 +100,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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
10
util/util.go
@@ -77,6 +77,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?!
|
||||
|
||||
@@ -152,3 +152,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)
|
||||
}
|
||||
|
||||
5322
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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
154
web/public/static/langs/bg.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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": "Разделени със запетая етикети, напр. внимание, диск",
|
||||
"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}}) или по-висок"
|
||||
}
|
||||
154
web/public/static/langs/de.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
156
web/public/static/langs/en.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"action_bar_settings": "Settings",
|
||||
"action_bar_send_test_notification": "Send test notification",
|
||||
"action_bar_clear_notifications": "Clear all notifications",
|
||||
"action_bar_unsubscribe": "Unsubscribe",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"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",
|
||||
"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_copied_to_clipboard": "Copied to clipboard",
|
||||
"notifications_tags": "Tags",
|
||||
"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_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_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_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_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
||||
"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_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_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_drop_file_here": "Drop file here",
|
||||
"emoji_picker_search_placeholder": "Search emoji",
|
||||
"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_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_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_add_button": "Add 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 …"
|
||||
}
|
||||
154
web/public/static/langs/es.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
56
web/public/static/langs/fr.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"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é min.",
|
||||
"publish_dialog_priority_low": "Basse priorité",
|
||||
"publish_dialog_priority_default": "Priorité par défaut",
|
||||
"publish_dialog_base_url_label": "URL du serveur",
|
||||
"publish_dialog_base_url_placeholder": "URL du serveur, 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é max.",
|
||||
"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 « Ajouter un abonnement » 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"
|
||||
}
|
||||
154
web/public/static/langs/id.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
154
web/public/static/langs/ja.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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": "最低"
|
||||
}
|
||||
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"
|
||||
}
|
||||
38
web/public/static/langs/pt_BR.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
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": "Высокий приоритет и выше"
|
||||
}
|
||||
154
web/public/static/langs/tr.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -92,6 +92,19 @@ class SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
async updateNotification(notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await db.notifications.put({ ...notification });
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error updating notification`, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteNotification(notificationId) {
|
||||
await db.notifications.delete(notificationId);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
|
||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||
export const expandSecureUrl = (url) => `https://${url}`;
|
||||
|
||||
export const validUrl = (url) => {
|
||||
return url.match(/^https?:\/\//);
|
||||
return url.match(/^https?:\/\/.+/);
|
||||
}
|
||||
|
||||
export const validTopic = (topic) => {
|
||||
@@ -104,6 +105,18 @@ export const encodeBase64Url = (s) => {
|
||||
return Base64.encodeURI(s);
|
||||
}
|
||||
|
||||
export const maybeAppendActionErrors = (message, notification) => {
|
||||
const actionErrors = (notification.actions ?? [])
|
||||
.map(action => action.error)
|
||||
.filter(action => !!action)
|
||||
.join("\n")
|
||||
if (actionErrors.length === 0) {
|
||||
return message;
|
||||
} else {
|
||||
return `${message}\n\n${actionErrors}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const shuffle = (arr) => {
|
||||
let j, x;
|
||||
for (let index = arr.length - 1; index > 0; index--) {
|
||||
@@ -115,6 +128,13 @@ export const shuffle = (arr) => {
|
||||
return arr;
|
||||
}
|
||||
|
||||
export const splitNoEmpty = (s, delimiter) => {
|
||||
return s
|
||||
.split(delimiter)
|
||||
.map(x => x.trim())
|
||||
.filter(x => x !== "");
|
||||
}
|
||||
|
||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||
export const hashCode = async (s) => {
|
||||
let hash = 0;
|
||||
@@ -145,17 +165,38 @@ export const openUrl = (url) => {
|
||||
};
|
||||
|
||||
export const sounds = {
|
||||
"beep": beep,
|
||||
"juntos": juntos,
|
||||
"pristine": pristine,
|
||||
"ding": ding,
|
||||
"dadum": dadum,
|
||||
"pop": pop,
|
||||
"pop-swoosh": popSwoosh
|
||||
"ding": {
|
||||
file: ding,
|
||||
label: "Ding"
|
||||
},
|
||||
"juntos": {
|
||||
file: juntos,
|
||||
label: "Juntos"
|
||||
},
|
||||
"pristine": {
|
||||
file: pristine,
|
||||
label: "Pristine"
|
||||
},
|
||||
"dadum": {
|
||||
file: dadum,
|
||||
label: "Dadum"
|
||||
},
|
||||
"pop": {
|
||||
file: pop,
|
||||
label: "Pop"
|
||||
},
|
||||
"pop-swoosh": {
|
||||
file: popSwoosh,
|
||||
label: "Pop swoosh"
|
||||
},
|
||||
"beep": {
|
||||
file: beep,
|
||||
label: "Beep"
|
||||
}
|
||||
};
|
||||
|
||||
export const playSound = async (sound) => {
|
||||
const audio = new Audio(sounds[sound]);
|
||||
export const playSound = async (id) => {
|
||||
const audio = new Audio(sounds[id].file);
|
||||
return audio.play();
|
||||
};
|
||||
|
||||
|
||||
@@ -22,14 +22,17 @@ import api from "../app/Api";
|
||||
import routes from "./routes";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import logo from "../img/ntfy.svg";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Portal, Snackbar} from "@mui/material";
|
||||
|
||||
const ActionBar = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
let title = "ntfy";
|
||||
if (props.selected) {
|
||||
title = topicShortUrl(props.selected.baseUrl, props.selected.topic);
|
||||
} else if (location.pathname === "/settings") {
|
||||
title = "Settings";
|
||||
title = t("action_bar_settings");
|
||||
}
|
||||
return (
|
||||
<AppBar position="fixed" sx={{
|
||||
@@ -66,8 +69,10 @@ const ActionBar = (props) => {
|
||||
|
||||
// Originally from https://mui.com/components/menus/#MenuListComposition.js
|
||||
const SettingsIcons = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const anchorRef = useRef(null);
|
||||
const subscription = props.subscription;
|
||||
|
||||
@@ -105,7 +110,7 @@ const SettingsIcons = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestMessage = () => {
|
||||
const handleSendTestMessage = async () => {
|
||||
const baseUrl = props.subscription.baseUrl;
|
||||
const topic = props.subscription.topic;
|
||||
const tags = shuffle([
|
||||
@@ -121,18 +126,30 @@ const SettingsIcons = (props) => {
|
||||
"Titles are optional, did you know that?",
|
||||
"ntfy is open source, and will always be free. Cool, right?",
|
||||
"I don't really like apples",
|
||||
"My favorite TV show is The Wire. You should watch it!"
|
||||
"My favorite TV show is The Wire. You should watch it!",
|
||||
"You can attach files and URLs to messages too",
|
||||
"You can delay messages up to 3 days"
|
||||
])[0];
|
||||
const nowSeconds = Math.round(Date.now()/1000);
|
||||
const message = shuffle([
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(Date.now())} right now. Is that early or late?`,
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
|
||||
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
||||
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
||||
`Alright then, it's ${formatShortDateTime(Date.now())} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/annoucements.`,
|
||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
|
||||
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
|
||||
])[0];
|
||||
api.publish(baseUrl, topic, message, title, priority, tags);
|
||||
try {
|
||||
await api.publish(baseUrl, topic, message, {
|
||||
title: title,
|
||||
priority: priority,
|
||||
tags: tags
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`[ActionBar] Error publishing message`, e);
|
||||
setSnackOpen(true);
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
@@ -178,15 +195,23 @@ const SettingsIcons = (props) => {
|
||||
<Paper>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
|
||||
<MenuItem onClick={handleSendTestMessage}>Send test notification</MenuItem>
|
||||
<MenuItem onClick={handleClearAll}>Clear all notifications</MenuItem>
|
||||
<MenuItem onClick={handleUnsubscribe}>Unsubscribe</MenuItem>
|
||||
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
|
||||
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
|
||||
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("message_bar_error_publishing")}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { Suspense } from "react";
|
||||
import {useEffect, useState} from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {ThemeProvider} from '@mui/material/styles';
|
||||
@@ -17,29 +18,34 @@ import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from
|
||||
import {expandUrl} from "../app/utils";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import routes from "./routes";
|
||||
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
|
||||
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
|
||||
import PublishDialog from "./PublishDialog";
|
||||
import Messaging from "./Messaging";
|
||||
import "./i18n"; // Translations!
|
||||
import {Backdrop, CircularProgress} from "@mui/material";
|
||||
|
||||
// TODO add drag and drop
|
||||
// TODO races when two tabs are open
|
||||
// TODO investigate service workers
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline/>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route element={<Layout/>}>
|
||||
<Route path={routes.root} element={<AllSubscriptions/>}/>
|
||||
<Route path={routes.settings} element={<Preferences/>}/>
|
||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline/>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route element={<Layout/>}>
|
||||
<Route path={routes.root} element={<AllSubscriptions/>}/>
|
||||
<Route path={routes.settings} element={<Preferences/>}/>
|
||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +64,7 @@ const Layout = () => {
|
||||
const params = useParams();
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||
const users = useLiveQuery(() => userManager.all());
|
||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||
@@ -67,7 +74,7 @@ const Layout = () => {
|
||||
});
|
||||
|
||||
useConnectionListeners(subscriptions, users);
|
||||
useLocalStorageMigration();
|
||||
useBackgroundProcesses();
|
||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||
|
||||
return (
|
||||
@@ -84,11 +91,17 @@ const Layout = () => {
|
||||
mobileDrawerOpen={mobileDrawerOpen}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
onNotificationGranted={setNotificationsGranted}
|
||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
/>
|
||||
<Main>
|
||||
<Toolbar/>
|
||||
<Outlet context={{ subscriptions, selected }}/>
|
||||
</Main>
|
||||
<Messaging
|
||||
selected={selected}
|
||||
dialogOpenMode={sendDialogOpenMode}
|
||||
onDialogOpenModeChange={setSendDialogOpenMode}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +127,18 @@ const Main = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Loader = () => (
|
||||
<Backdrop
|
||||
open={true}
|
||||
sx={{
|
||||
zIndex: 100000,
|
||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="success" disableShrink />
|
||||
</Backdrop>
|
||||
);
|
||||
|
||||
const updateTitle = (newNotificationsCount) => {
|
||||
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||
}
|
||||
|
||||
38
web/src/components/AttachmentIcon.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import fileDocument from "../img/file-document.svg";
|
||||
import fileImage from "../img/file-image.svg";
|
||||
import fileVideo from "../img/file-video.svg";
|
||||
import fileAudio from "../img/file-audio.svg";
|
||||
import fileApp from "../img/file-app.svg";
|
||||
|
||||
const AttachmentIcon = (props) => {
|
||||
const type = props.type;
|
||||
let imageFile;
|
||||
if (!type) {
|
||||
imageFile = fileDocument;
|
||||
} else if (type.startsWith('image/')) {
|
||||
imageFile = fileImage;
|
||||
} else if (type.startsWith('video/')) {
|
||||
imageFile = fileVideo;
|
||||
} else if (type.startsWith('audio/')) {
|
||||
imageFile = fileAudio;
|
||||
} else if (type === "application/vnd.android.package-archive") {
|
||||
imageFile = fileApp;
|
||||
} else {
|
||||
imageFile = fileDocument;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '28px',
|
||||
height: '28px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttachmentIcon;
|
||||
29
web/src/components/DialogFooter.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
|
||||
const DialogFooter = (props) => {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: '24px',
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
<DialogContentText component="div" sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '4px'
|
||||
}}>
|
||||
{props.status}
|
||||
</DialogContentText>
|
||||
<DialogActions sx={{paddingRight: 2}}>
|
||||
{props.children}
|
||||
</DialogActions>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogFooter;
|
||||
171
web/src/components/EmojiPicker.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import {useRef, useState} from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {rawEmojis} from '../app/emojis';
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {Close} from "@mui/icons-material";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import {splitNoEmpty} from "../app/utils";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
// Create emoji list by category and create a search base (string with all search words)
|
||||
//
|
||||
// This also filters emojis that are not supported by Desktop Chrome.
|
||||
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
|
||||
|
||||
const emojisByCategory = {};
|
||||
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
|
||||
const maxSupportedVersionForDesktopChrome = 11;
|
||||
rawEmojis.forEach(emoji => {
|
||||
if (!emojisByCategory[emoji.category]) {
|
||||
emojisByCategory[emoji.category] = [];
|
||||
}
|
||||
try {
|
||||
const unicodeVersion = parseFloat(emoji.unicode_version);
|
||||
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
|
||||
if (supportedEmoji) {
|
||||
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
|
||||
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
|
||||
emojisByCategory[emoji.category].push(emojiWithSearchBase);
|
||||
}
|
||||
} catch (e) {
|
||||
// Nothing. Ignore.
|
||||
}
|
||||
});
|
||||
|
||||
const EmojiPicker = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const open = Boolean(props.anchorEl);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef(null);
|
||||
const searchFields = splitNoEmpty(search.toLowerCase(), " ");
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setSearch("");
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={props.anchorEl}
|
||||
placement="bottom-start"
|
||||
sx={{ zIndex: 10005 }}
|
||||
transition
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<ClickAwayListener onClickAway={props.onClose}>
|
||||
<Fade {...TransitionProps} timeout={350}>
|
||||
<Box sx={{
|
||||
boxShadow: 3,
|
||||
padding: 2,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 1,
|
||||
width: "380px",
|
||||
maxHeight: "300px",
|
||||
backgroundColor: 'background.paper',
|
||||
overflowY: "scroll"
|
||||
}}>
|
||||
<TextField
|
||||
inputRef={searchRef}
|
||||
margin="dense"
|
||||
size="small"
|
||||
placeholder={t("emoji_picker_search_placeholder")}
|
||||
value={search}
|
||||
onChange={ev => setSearch(ev.target.value)}
|
||||
type="text"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
||||
InputProps={{
|
||||
endAdornment:
|
||||
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end"><Close/></IconButton>
|
||||
</InputAdornment>
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
|
||||
{Object.keys(emojisByCategory).map(category =>
|
||||
<Category
|
||||
key={category}
|
||||
title={category}
|
||||
emojis={emojisByCategory[category]}
|
||||
search={searchFields}
|
||||
onPick={props.onEmojiPick}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
</ClickAwayListener>
|
||||
)}
|
||||
</Popper>
|
||||
);
|
||||
};
|
||||
|
||||
const Category = (props) => {
|
||||
const showTitle = props.search.length === 0;
|
||||
return (
|
||||
<>
|
||||
{showTitle &&
|
||||
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
|
||||
{props.title}
|
||||
</Typography>
|
||||
}
|
||||
{props.emojis.map(emoji =>
|
||||
<Emoji
|
||||
key={emoji.aliases[0]}
|
||||
emoji={emoji}
|
||||
search={props.search}
|
||||
onClick={() => props.onPick(emoji.aliases[0])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Emoji = (props) => {
|
||||
const emoji = props.emoji;
|
||||
const matches = emojiMatches(emoji, props.search);
|
||||
return (
|
||||
<EmojiDiv
|
||||
onClick={props.onClick}
|
||||
title={`${emoji.description} (${emoji.aliases[0]})`}
|
||||
style={{ display: (matches) ? '' : 'none' }}
|
||||
>
|
||||
{props.emoji.emoji}
|
||||
</EmojiDiv>
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiDiv = styled("div")({
|
||||
fontSize: "30px",
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
marginTop: "8px",
|
||||
marginBottom: "8px",
|
||||
marginRight: "8px",
|
||||
lineHeight: "30px",
|
||||
cursor: "pointer",
|
||||
opacity: 0.85,
|
||||
"&:hover": {
|
||||
opacity: 1
|
||||
}
|
||||
});
|
||||
|
||||
const emojiMatches = (emoji, words) => {
|
||||
if (words.length === 0) {
|
||||
return true;
|
||||
}
|
||||
for (const word of words) {
|
||||
if (emoji.searchBase.indexOf(word) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from "react";
|
||||
import StackTrace from "stacktrace-js";
|
||||
import {CircularProgress} from "@mui/material";
|
||||
import {CircularProgress, Link} from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import {Trans, withTranslation} from "react-i18next";
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
class ErrorBoundaryImpl extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -45,22 +46,28 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>Oh no, ntfy crashed 😮</h2>
|
||||
<h2>{t("error_boundary_title")} 😮</h2>
|
||||
<p>
|
||||
This should obviously not happen. Very sorry about this.<br/>
|
||||
If you have a minute, please <a href="https://github.com/binwiederhier/ntfy/issues">report this on GitHub</a>, or let us
|
||||
know via <a href="https://discord.gg/cT7ECsZj9w">Discord</a> or <a href="https://matrix.to/#/#ntfy:matrix.org">Matrix</a>.
|
||||
<Trans
|
||||
i18nKey="error_boundary_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>Copy stack trace</Button>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
|
||||
</p>
|
||||
<h3>Stack trace</h3>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack
|
||||
? <pre>{this.state.niceStack}</pre>
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> Gather more info ...</>}
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -69,4 +76,5 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
|
||||
export default ErrorBoundary;
|
||||
|
||||
112
web/src/components/Messaging.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import {useState} from 'react';
|
||||
import Navigation from "./Navigation";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import api from "../app/Api";
|
||||
import PublishDialog from "./PublishDialog";
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import {Portal, Snackbar} from "@mui/material";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
const Messaging = (props) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
|
||||
const dialogOpenMode = props.dialogOpenMode;
|
||||
const subscription = props.selected;
|
||||
|
||||
const handleOpenDialogClick = () => {
|
||||
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
props.onDialogOpenModeChange("");
|
||||
setDialogKey(prev => prev+1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{subscription && <MessageBar
|
||||
subscription={subscription}
|
||||
message={message}
|
||||
onMessageChange={setMessage}
|
||||
onOpenDialogClick={handleOpenDialogClick}
|
||||
/>}
|
||||
<PublishDialog
|
||||
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
|
||||
openMode={dialogOpenMode}
|
||||
baseUrl={subscription?.baseUrl ?? window.location.origin}
|
||||
topic={subscription?.topic ?? ""}
|
||||
message={message}
|
||||
onClose={handleDialogClose}
|
||||
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
|
||||
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MessageBar = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const subscription = props.subscription;
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const handleSendClick = async () => {
|
||||
try {
|
||||
await api.publish(subscription.baseUrl, subscription.topic, props.message);
|
||||
} catch (e) {
|
||||
console.log(`[MessageBar] Error publishing message`, e);
|
||||
setSnackOpen(true);
|
||||
}
|
||||
props.onMessageChange("");
|
||||
};
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
display: "flex",
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
padding: 2,
|
||||
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
|
||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
||||
}}
|
||||
>
|
||||
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick}>
|
||||
<KeyboardArrowUpIcon/>
|
||||
</IconButton>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
placeholder={t("message_bar_type_message")}
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={props.message}
|
||||
onChange={ev => props.onMessageChange(ev.target.value)}
|
||||
onKeyPress={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSendClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick}>
|
||||
<SendIcon/>
|
||||
</IconButton>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("message_bar_error_publishing")}
|
||||
/>
|
||||
</Portal>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Messaging;
|
||||
@@ -19,11 +19,12 @@ import routes from "./routes";
|
||||
import {ConnectionState} from "../app/Connection";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material";
|
||||
import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
|
||||
import Box from "@mui/material/Box";
|
||||
import notifier from "../app/Notifier";
|
||||
import config from "../app/config";
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
const navWidth = 280;
|
||||
|
||||
@@ -61,6 +62,7 @@ const Navigation = (props) => {
|
||||
Navigation.width = navWidth;
|
||||
|
||||
const NavList = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
|
||||
@@ -95,14 +97,14 @@ const NavList = (props) => {
|
||||
{!showSubscriptionsList &&
|
||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
||||
<ListItemText primary="All notifications"/>
|
||||
<ListItemText primary={t("nav_button_all_notifications")}/>
|
||||
</ListItemButton>}
|
||||
{showSubscriptionsList &&
|
||||
<>
|
||||
<ListSubheader>Subscribed topics</ListSubheader>
|
||||
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
|
||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
||||
<ListItemText primary="All notifications"/>
|
||||
<ListItemText primary={t("nav_button_all_notifications")}/>
|
||||
</ListItemButton>
|
||||
<SubscriptionList
|
||||
subscriptions={props.subscriptions}
|
||||
@@ -112,15 +114,19 @@ const NavList = (props) => {
|
||||
</>}
|
||||
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
||||
<ListItemIcon><SettingsIcon/></ListItemIcon>
|
||||
<ListItemText primary="Settings"/>
|
||||
<ListItemText primary={t("nav_button_settings")}/>
|
||||
</ListItemButton>
|
||||
<ListItemButton onClick={() => openUrl("/docs")}>
|
||||
<ListItemIcon><ArticleIcon/></ListItemIcon>
|
||||
<ListItemText primary="Documentation"/>
|
||||
<ListItemText primary={t("nav_button_documentation")}/>
|
||||
</ListItemButton>
|
||||
<ListItemButton onClick={() => props.onPublishMessageClick()}>
|
||||
<ListItemIcon><Send/></ListItemIcon>
|
||||
<ListItemText primary={t("nav_button_publish_message")}/>
|
||||
</ListItemButton>
|
||||
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
|
||||
<ListItemIcon><AddIcon/></ListItemIcon>
|
||||
<ListItemText primary="Add subscription"/>
|
||||
<ListItemText primary={t("nav_button_subscribe")}/>
|
||||
</ListItemButton>
|
||||
</List>
|
||||
<SubscribeDialog
|
||||
@@ -175,20 +181,19 @@ const SubscriptionItem = (props) => {
|
||||
};
|
||||
|
||||
const NotificationGrantAlert = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Alert severity="warning" sx={{paddingTop: 2}}>
|
||||
<AlertTitle>Notifications are disabled</AlertTitle>
|
||||
<Typography gutterBottom>
|
||||
Grant your browser permission to display desktop notifications.
|
||||
</Typography>
|
||||
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
|
||||
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
|
||||
<Button
|
||||
sx={{float: 'right'}}
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={props.onRequestPermissionClick}
|
||||
>
|
||||
Grant now
|
||||
{t("alert_grant_button")}
|
||||
</Button>
|
||||
</Alert>
|
||||
<Divider/>
|
||||
@@ -197,13 +202,12 @@ const NotificationGrantAlert = (props) => {
|
||||
};
|
||||
|
||||
const NotificationNotSupportedAlert = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Alert severity="warning" sx={{paddingTop: 2}}>
|
||||
<AlertTitle>Notifications not supported</AlertTitle>
|
||||
<Typography gutterBottom>
|
||||
Notifications are not supported in your browser.
|
||||
</Typography>
|
||||
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
|
||||
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
|
||||
</Alert>
|
||||
<Divider/>
|
||||
</>
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
formatBytes,
|
||||
formatMessage,
|
||||
formatShortDateTime,
|
||||
formatTitle,
|
||||
openUrl, shortUrl,
|
||||
formatTitle, maybeAppendActionErrors,
|
||||
openUrl,
|
||||
shortUrl,
|
||||
topicShortUrl,
|
||||
unmatchedTags
|
||||
} from "../app/utils";
|
||||
@@ -32,16 +33,13 @@ import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import fileApp from "../img/file-app.svg";
|
||||
import fileAudio from "../img/file-audio.svg";
|
||||
import fileDocument from "../img/file-document.svg";
|
||||
import fileImage from "../img/file-image.svg";
|
||||
import fileVideo from "../img/file-video.svg";
|
||||
import priority1 from "../img/priority-1.svg";
|
||||
import priority2 from "../img/priority-2.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
import priority5 from "../img/priority-5.svg";
|
||||
import logoOutline from "../img/ntfy-outline.svg";
|
||||
import AttachmentIcon from "./AttachmentIcon";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
|
||||
const Notifications = (props) => {
|
||||
if (props.mode === "all") {
|
||||
@@ -60,7 +58,7 @@ const AllSubscriptions = (props) => {
|
||||
} else if (notifications.length === 0) {
|
||||
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
|
||||
}
|
||||
return <NotificationList key="all" notifications={notifications}/>;
|
||||
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
|
||||
}
|
||||
|
||||
const SingleSubscription = (props) => {
|
||||
@@ -71,10 +69,11 @@ const SingleSubscription = (props) => {
|
||||
} else if (notifications.length === 0) {
|
||||
return <NoNotifications subscription={subscription}/>;
|
||||
}
|
||||
return <NotificationList id={subscription.id} notifications={notifications}/>;
|
||||
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
|
||||
}
|
||||
|
||||
const NotificationList = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const pageSize = 20;
|
||||
const notifications = props.notifications;
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
@@ -97,7 +96,13 @@ const NotificationList = (props) => {
|
||||
scrollThreshold={0.7}
|
||||
scrollableTarget="main"
|
||||
>
|
||||
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
sx={{
|
||||
marginTop: 3,
|
||||
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{notifications.slice(0, count).map(notification =>
|
||||
<NotificationItem
|
||||
@@ -109,7 +114,7 @@ const NotificationList = (props) => {
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message="Copied to clipboard"
|
||||
message={t("notifications_copied_to_clipboard")}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
@@ -118,14 +123,14 @@ const NotificationList = (props) => {
|
||||
}
|
||||
|
||||
const NotificationItem = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const notification = props.notification;
|
||||
const subscriptionId = notification.subscriptionId;
|
||||
const attachment = notification.attachment;
|
||||
const date = formatShortDateTime(notification.time);
|
||||
const otherTags = unmatchedTags(notification.tags);
|
||||
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
|
||||
const handleDelete = async () => {
|
||||
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
|
||||
console.log(`[Notifications] Deleting notification ${notification.id}`);
|
||||
await subscriptionManager.deleteNotification(notification.id)
|
||||
}
|
||||
const handleCopy = (s) => {
|
||||
@@ -133,9 +138,10 @@ const NotificationItem = (props) => {
|
||||
props.onShowSnack();
|
||||
};
|
||||
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
|
||||
const showAttachmentActions = attachment && !expired;
|
||||
const showClickAction = notification.click;
|
||||
const showActions = showAttachmentActions || showClickAction;
|
||||
const hasAttachmentActions = attachment && !expired;
|
||||
const hasClickAction = notification.click;
|
||||
const hasUserActions = notification.actions && notification.actions.length > 0;
|
||||
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
||||
return (
|
||||
<Card sx={{ minWidth: 275, padding: 1 }}>
|
||||
<CardContent>
|
||||
@@ -156,28 +162,31 @@ const NotificationItem = (props) => {
|
||||
</svg>}
|
||||
</Typography>
|
||||
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
|
||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{autolink(formatMessage(notification))}</Typography>
|
||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
|
||||
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
|
||||
</Typography>
|
||||
{attachment && <Attachment attachment={attachment}/>}
|
||||
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
|
||||
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>}
|
||||
</CardContent>
|
||||
{showActions &&
|
||||
<CardActions sx={{paddingTop: 0}}>
|
||||
{showAttachmentActions && <>
|
||||
<Tooltip title="Copy attachment URL to clipboard">
|
||||
<Button onClick={() => handleCopy(attachment.url)}>Copy URL</Button>
|
||||
{hasAttachmentActions && <>
|
||||
<Tooltip title={t("notifications_attachment_copy_url_title")}>
|
||||
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={`Go to ${attachment.url}`}>
|
||||
<Button onClick={() => openUrl(attachment.url)}>Open attachment</Button>
|
||||
<Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}>
|
||||
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
|
||||
</Tooltip>
|
||||
</>}
|
||||
{showClickAction && <>
|
||||
<Tooltip title="Copy link URL to clipboard">
|
||||
<Button onClick={() => handleCopy(notification.click)}>Copy link</Button>
|
||||
{hasClickAction && <>
|
||||
<Tooltip title={t("notifications_click_copy_url_title")}>
|
||||
<Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={`Go to ${notification.click}`}>
|
||||
<Button onClick={() => openUrl(notification.click)}>Open link</Button>
|
||||
<Tooltip title={t("notifications_actions_open_url_title", { url: notification.click })}>
|
||||
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
|
||||
</Tooltip>
|
||||
</>}
|
||||
{hasUserActions && <UserActions notification={notification}/>}
|
||||
</CardActions>}
|
||||
</Card>
|
||||
);
|
||||
@@ -206,6 +215,7 @@ const priorityFiles = {
|
||||
};
|
||||
|
||||
const Attachment = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const attachment = props.attachment;
|
||||
const expired = attachment.expires && attachment.expires < Date.now()/1000;
|
||||
const expires = attachment.expires && attachment.expires > Date.now()/1000;
|
||||
@@ -222,10 +232,10 @@ const Attachment = (props) => {
|
||||
infos.push(formatBytes(attachment.size));
|
||||
}
|
||||
if (expires) {
|
||||
infos.push(`link expires ${formatShortDateTime(attachment.expires)}`);
|
||||
infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) }));
|
||||
}
|
||||
if (expired) {
|
||||
infos.push(`download link expired`);
|
||||
infos.push(t("notifications_attachment_link_expired"));
|
||||
}
|
||||
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
|
||||
|
||||
@@ -239,7 +249,7 @@ const Attachment = (props) => {
|
||||
padding: 1,
|
||||
borderRadius: '4px',
|
||||
}}>
|
||||
<Icon type={attachment.type}/>
|
||||
<AttachmentIcon type={attachment.type}/>
|
||||
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
||||
<b>{attachment.name}</b>
|
||||
{maybeInfoText}
|
||||
@@ -268,7 +278,7 @@ const Attachment = (props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon type={attachment.type}/>
|
||||
<AttachmentIcon type={attachment.type}/>
|
||||
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
||||
<b>{attachment.name}</b>
|
||||
{maybeInfoText}
|
||||
@@ -323,112 +333,173 @@ const Image = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = (props) => {
|
||||
const type = props.type;
|
||||
let imageFile;
|
||||
if (!type) {
|
||||
imageFile = fileDocument;
|
||||
} else if (type.startsWith('image/')) {
|
||||
imageFile = fileImage;
|
||||
} else if (type.startsWith('video/')) {
|
||||
imageFile = fileVideo;
|
||||
} else if (type.startsWith('audio/')) {
|
||||
imageFile = fileAudio;
|
||||
} else if (type === "application/vnd.android.package-archive") {
|
||||
imageFile = fileApp;
|
||||
} else {
|
||||
imageFile = fileDocument;
|
||||
}
|
||||
const UserActions = (props) => {
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '28px',
|
||||
height: '28px'
|
||||
}}
|
||||
/>
|
||||
<>{props.notification.actions.map(action =>
|
||||
<UserAction key={action.id} notification={props.notification} action={action}/>)}</>
|
||||
);
|
||||
};
|
||||
|
||||
const UserAction = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const notification = props.notification;
|
||||
const action = props.action;
|
||||
if (action.action === "broadcast") {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_not_supported")}>
|
||||
<span><Button disabled>{action.label}</Button></span>
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (action.action === "view") {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
|
||||
<Button onClick={() => openUrl(action.url)}>{action.label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (action.action === "http") {
|
||||
const method = action.method ?? "POST";
|
||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}>
|
||||
<Button onClick={() => performHttpAction(notification, action)}>{label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null; // Others
|
||||
};
|
||||
|
||||
const performHttpAction = async (notification, action) => {
|
||||
console.log(`[Notifications] Performing HTTP user action`, action);
|
||||
try {
|
||||
updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null);
|
||||
const response = await fetch(action.url, {
|
||||
method: action.method ?? "POST",
|
||||
headers: action.headers ?? {},
|
||||
body: action.body ?? ""
|
||||
});
|
||||
console.log(`[Notifications] HTTP user action response`, response);
|
||||
const success = response.status >= 200 && response.status <= 299;
|
||||
if (success) {
|
||||
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
|
||||
} else {
|
||||
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[Notifications] HTTP action failed`, e);
|
||||
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateActionStatus = (notification, action, progress, error) => {
|
||||
notification.actions = notification.actions.map(a => {
|
||||
if (a.id !== action.id) {
|
||||
return a;
|
||||
}
|
||||
return { ...a, progress: progress, error: error };
|
||||
});
|
||||
subscriptionManager.updateNotification(notification);
|
||||
}
|
||||
|
||||
const ACTION_PROGRESS_ONGOING = 1;
|
||||
const ACTION_PROGRESS_SUCCESS = 2;
|
||||
const ACTION_PROGRESS_FAILED = 3;
|
||||
|
||||
const ACTION_LABEL_SUFFIX = {
|
||||
[ACTION_PROGRESS_ONGOING]: " …",
|
||||
[ACTION_PROGRESS_SUCCESS]: " ✔",
|
||||
[ACTION_PROGRESS_FAILED]: " ❌"
|
||||
};
|
||||
|
||||
const NoNotifications = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64" alt="No notifications"/><br />
|
||||
You haven't received any notifications for this topic yet.
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
{t("notifications_none_for_topic_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
To send notifications to this topic, simply PUT or POST to the topic URL.
|
||||
{t("notifications_none_for_topic_description")}
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Example:<br/>
|
||||
{t("notifications_example")}:<br/>
|
||||
<tt>
|
||||
$ curl -d "Hi" {shortUrl}
|
||||
</tt>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
|
||||
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
|
||||
<ForMoreDetails/>
|
||||
</Paragraph>
|
||||
</VerticallyCenteredContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const NoNotificationsWithoutSubscription = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const subscription = props.subscriptions[0];
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64" alt="No notifications"/><br />
|
||||
You haven't received any notifications.
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
{t("notifications_none_for_any_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
To send notifications to a topic, simply PUT or POST to the topic URL. Here's
|
||||
an example using one of your topics.
|
||||
{t("notifications_none_for_any_description")}
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Example:<br/>
|
||||
{t("notifications_example")}:<br/>
|
||||
<tt>
|
||||
$ curl -d "Hi" {shortUrl}
|
||||
</tt>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
|
||||
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
|
||||
<ForMoreDetails/>
|
||||
</Paragraph>
|
||||
</VerticallyCenteredContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const NoSubscriptions = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64" alt="No topics"/><br />
|
||||
It looks like you don't have any subscriptions yet.
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
{t("notifications_no_subscriptions_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
Click the "Add subscription" link to create or subscribe to a topic. After that, you can send messages
|
||||
via PUT or POST and you'll receive notifications here.
|
||||
{t("notifications_no_subscriptions_description", {
|
||||
linktext: t("nav_button_subscribe")
|
||||
})}
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
For more information, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
|
||||
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
|
||||
<ForMoreDetails/>
|
||||
</Paragraph>
|
||||
</VerticallyCenteredContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ForMoreDetails = () => {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="notifications_more_details"
|
||||
components={{
|
||||
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>,
|
||||
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Loading = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<VerticallyCenteredContainer>
|
||||
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
|
||||
Loading notifications ...
|
||||
{t("notifications_loading")}
|
||||
</Typography>
|
||||
</VerticallyCenteredContainer>
|
||||
);
|
||||
|
||||
@@ -32,13 +32,20 @@ import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import userManager from "../app/UserManager";
|
||||
import {playSound} from "../app/utils";
|
||||
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import priority1 from "../img/priority-1.svg";
|
||||
import priority2 from "../img/priority-2.svg";
|
||||
import priority3 from "../img/priority-3.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
import priority5 from "../img/priority-5.svg";
|
||||
|
||||
const Preferences = () => {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||
<Stack spacing={3}>
|
||||
<Notifications/>
|
||||
<Appearance/>
|
||||
<Users/>
|
||||
</Stack>
|
||||
</Container>
|
||||
@@ -46,10 +53,11 @@ const Preferences = () => {
|
||||
};
|
||||
|
||||
const Notifications = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card sx={{p: 3}}>
|
||||
<Typography variant="h5">
|
||||
Notifications
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_notifications_title")}
|
||||
</Typography>
|
||||
<PrefGroup>
|
||||
<Sound/>
|
||||
@@ -60,8 +68,8 @@ const Notifications = () => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const Sound = () => {
|
||||
const { t } = useTranslation();
|
||||
const sound = useLiveQuery(async () => prefs.sound());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setSound(ev.target.value);
|
||||
@@ -69,19 +77,19 @@ const Sound = () => {
|
||||
if (!sound) {
|
||||
return null; // While loading
|
||||
}
|
||||
let description;
|
||||
if (sound === "none") {
|
||||
description = t("prefs_notifications_sound_description_none");
|
||||
} else {
|
||||
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
|
||||
}
|
||||
return (
|
||||
<Pref title="Notification sound">
|
||||
<Pref title={t("prefs_notifications_sound_title")} description={description}>
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
||||
<Select value={sound} onChange={handleChange}>
|
||||
<MenuItem value={"none"}>No sound</MenuItem>
|
||||
<MenuItem value={"ding"}>Ding</MenuItem>
|
||||
<MenuItem value={"juntos"}>Juntos</MenuItem>
|
||||
<MenuItem value={"pristine"}>Pristine</MenuItem>
|
||||
<MenuItem value={"dadum"}>Dadum</MenuItem>
|
||||
<MenuItem value={"pop"}>Pop</MenuItem>
|
||||
<MenuItem value={"pop-swoosh"}>Pop swoosh</MenuItem>
|
||||
<MenuItem value={"beep"}>Beep</MenuItem>
|
||||
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
|
||||
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"}>
|
||||
@@ -93,6 +101,7 @@ const Sound = () => {
|
||||
};
|
||||
|
||||
const MinPriority = () => {
|
||||
const { t } = useTranslation();
|
||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setMinPriority(ev.target.value);
|
||||
@@ -100,15 +109,33 @@ const MinPriority = () => {
|
||||
if (!minPriority) {
|
||||
return null; // While loading
|
||||
}
|
||||
const priorities = {
|
||||
1: t("priority_min"),
|
||||
2: t("priority_low"),
|
||||
3: t("priority_default"),
|
||||
4: t("priority_high"),
|
||||
5: t("priority_max")
|
||||
}
|
||||
let description;
|
||||
if (minPriority === 1) {
|
||||
description = t("prefs_notifications_min_priority_description_any");
|
||||
} else if (minPriority === 5) {
|
||||
description = t("prefs_notifications_min_priority_description_max");
|
||||
} else {
|
||||
description = t("prefs_notifications_min_priority_description_x_or_higher", {
|
||||
number: minPriority,
|
||||
name: priorities[minPriority]
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Pref title="Minimum priority">
|
||||
<Pref title={t("prefs_notifications_min_priority_title")} description={description}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={minPriority} onChange={handleChange}>
|
||||
<MenuItem value={1}>Any priority</MenuItem>
|
||||
<MenuItem value={2}>Low priority and higher</MenuItem>
|
||||
<MenuItem value={3}>Default priority and higher</MenuItem>
|
||||
<MenuItem value={4}>High priority and higher</MenuItem>
|
||||
<MenuItem value={5}>Only max priority</MenuItem>
|
||||
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
|
||||
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
|
||||
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
|
||||
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
|
||||
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Pref>
|
||||
@@ -116,22 +143,32 @@ const MinPriority = () => {
|
||||
};
|
||||
|
||||
const DeleteAfter = () => {
|
||||
const { t } = useTranslation();
|
||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setDeleteAfter(ev.target.value);
|
||||
}
|
||||
if (!deleteAfter) {
|
||||
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
|
||||
return null; // While loading
|
||||
}
|
||||
const description = (() => {
|
||||
switch (deleteAfter) {
|
||||
case 0: return t("prefs_notifications_delete_after_never_description");
|
||||
case 10800: return t("prefs_notifications_delete_after_three_hours_description");
|
||||
case 86400: return t("prefs_notifications_delete_after_one_day_description");
|
||||
case 604800: return t("prefs_notifications_delete_after_one_week_description");
|
||||
case 2592000: return t("prefs_notifications_delete_after_one_month_description");
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Pref title="Delete notifications">
|
||||
<Pref title={t("prefs_notifications_delete_after_title")} description={description}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={deleteAfter} onChange={handleChange}>
|
||||
<MenuItem value={0}>Never</MenuItem>
|
||||
<MenuItem value={10800}>After three hours</MenuItem>
|
||||
<MenuItem value={86400}>After one day</MenuItem>
|
||||
<MenuItem value={604800}>After one week</MenuItem>
|
||||
<MenuItem value={2592000}>After one month</MenuItem>
|
||||
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
|
||||
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
|
||||
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
|
||||
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
|
||||
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Pref>
|
||||
@@ -140,10 +177,7 @@ const DeleteAfter = () => {
|
||||
|
||||
const PrefGroup = (props) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
@@ -151,30 +185,36 @@ const PrefGroup = (props) => {
|
||||
|
||||
const Pref = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
marginTop: "10px",
|
||||
marginBottom: "20px",
|
||||
}}>
|
||||
<div style={{
|
||||
flex: '1 0 30%',
|
||||
display: 'inline-flex',
|
||||
flex: '1 0 40%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '60px',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
paddingRight: '30px'
|
||||
}}>
|
||||
<b>{props.title}</b>
|
||||
<div><b>{props.title}</b></div>
|
||||
{props.description && <div><em>{props.description}</em></div>}
|
||||
</div>
|
||||
<div style={{
|
||||
flex: '1 0 calc(70% - 50px)',
|
||||
display: 'inline-flex',
|
||||
flex: '1 0 calc(60% - 50px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '60px',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Users = () => {
|
||||
const { t } = useTranslation();
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const users = useLiveQuery(() => userManager.all());
|
||||
@@ -196,18 +236,17 @@ const Users = () => {
|
||||
};
|
||||
return (
|
||||
<Card sx={{ padding: 1 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h5">
|
||||
Manage users
|
||||
<CardContent sx={{ paddingBottom: 1 }}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_users_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
Add/remove users for your protected topics here. Please note that username and password are
|
||||
stored in the browser's local storage.
|
||||
{t("prefs_users_description")}
|
||||
</Paragraph>
|
||||
{users?.length > 0 && <UserTable users={users}/>}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button onClick={handleAddClick}>Add user</Button>
|
||||
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
|
||||
<UserDialog
|
||||
key={`userAddDialog${dialogKey}`}
|
||||
open={dialogOpen}
|
||||
@@ -222,6 +261,7 @@ const Users = () => {
|
||||
};
|
||||
|
||||
const UserTable = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogUser, setDialogUser] = useState(null);
|
||||
@@ -254,8 +294,8 @@ const UserTable = (props) => {
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell>Service URL</TableCell>
|
||||
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
|
||||
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
|
||||
<TableCell/>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -265,7 +305,7 @@ const UserTable = (props) => {
|
||||
key={user.baseUrl}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell component="th" scope="row">{user.username}</TableCell>
|
||||
<TableCell component="th" scope="row" sx={{paddingLeft: 0}}>{user.username}</TableCell>
|
||||
<TableCell>{user.baseUrl}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={() => handleEditClick(user)}>
|
||||
@@ -291,6 +331,7 @@ const UserTable = (props) => {
|
||||
};
|
||||
|
||||
const UserDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -300,8 +341,12 @@ const UserDialog = (props) => {
|
||||
if (editMode) {
|
||||
return username.length > 0 && password.length > 0;
|
||||
}
|
||||
const baseUrlValid = validUrl(baseUrl);
|
||||
const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl);
|
||||
return !baseUrlExists && username.length > 0 && password.length > 0;
|
||||
return baseUrlValid
|
||||
&& !baseUrlExists
|
||||
&& username.length > 0
|
||||
&& password.length > 0;
|
||||
})();
|
||||
const handleSubmit = async () => {
|
||||
props.onSubmit({
|
||||
@@ -319,13 +364,13 @@ const UserDialog = (props) => {
|
||||
}, [editMode, props.user]);
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{editMode ? "Edit user" : "Add user"}</DialogTitle>
|
||||
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{!editMode && <TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="baseUrl"
|
||||
label="Service URL, e.g. https://ntfy.sh"
|
||||
label={t("prefs_users_dialog_base_url_label")}
|
||||
value={baseUrl}
|
||||
onChange={ev => setBaseUrl(ev.target.value)}
|
||||
type="url"
|
||||
@@ -336,7 +381,7 @@ const UserDialog = (props) => {
|
||||
autoFocus={editMode}
|
||||
margin="dense"
|
||||
id="username"
|
||||
label="Username, e.g. phil"
|
||||
label={t("prefs_users_dialog_username_label")}
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
type="text"
|
||||
@@ -346,7 +391,7 @@ const UserDialog = (props) => {
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="password"
|
||||
label="Password"
|
||||
label={t("prefs_users_dialog_password_label")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
@@ -355,11 +400,53 @@ const UserDialog = (props) => {
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onCancel}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? "Save" : "Add"}</Button>
|
||||
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Appearance = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card sx={{p: 3}}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_appearance_title")}
|
||||
</Typography>
|
||||
<PrefGroup>
|
||||
<Language/>
|
||||
</PrefGroup>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Language = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇧🇬", "🇩🇪", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
|
||||
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
||||
|
||||
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
||||
// Languages names from: https://www.omniglot.com/language/names.htm
|
||||
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
|
||||
|
||||
return (
|
||||
<Pref title={title}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={i18n.language} onChange={(ev) => i18n.changeLanguage(ev.target.value)}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="es">Español</MenuItem>
|
||||
<MenuItem value="bg">Български</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
|
||||
<MenuItem value="ru">Русский</MenuItem>
|
||||
<MenuItem value="tr">Türkçe</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Pref>
|
||||
)
|
||||
};
|
||||
|
||||
export default Preferences;
|
||||
|
||||
674
web/src/components/PublishDialog.js
Normal file
@@ -0,0 +1,674 @@
|
||||
import * as React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {NotificationItem} from "./Notifications";
|
||||
import theme from "./theme";
|
||||
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import priority1 from "../img/priority-1.svg";
|
||||
import priority2 from "../img/priority-2.svg";
|
||||
import priority3 from "../img/priority-3.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
import priority5 from "../img/priority-5.svg";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
|
||||
import {Close} from "@mui/icons-material";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
|
||||
import Box from "@mui/material/Box";
|
||||
import AttachmentIcon from "./AttachmentIcon";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import api from "../app/Api";
|
||||
import userManager from "../app/UserManager";
|
||||
import EmojiPicker from "./EmojiPicker";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
|
||||
const PublishDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageFocused, setMessageFocused] = useState(true);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [priority, setPriority] = useState(3);
|
||||
const [clickUrl, setClickUrl] = useState("");
|
||||
const [attachUrl, setAttachUrl] = useState("");
|
||||
const [attachFile, setAttachFile] = useState(null);
|
||||
const [filename, setFilename] = useState("");
|
||||
const [filenameEdited, setFilenameEdited] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [delay, setDelay] = useState("");
|
||||
const [publishAnother, setPublishAnother] = useState(false);
|
||||
|
||||
const [showTopicUrl, setShowTopicUrl] = useState("");
|
||||
const [showClickUrl, setShowClickUrl] = useState(false);
|
||||
const [showAttachUrl, setShowAttachUrl] = useState(false);
|
||||
const [showEmail, setShowEmail] = useState(false);
|
||||
const [showDelay, setShowDelay] = useState(false);
|
||||
|
||||
const showAttachFile = !!attachFile && !showAttachUrl;
|
||||
const attachFileInput = useRef();
|
||||
const [attachFileError, setAttachFileError] = useState("");
|
||||
|
||||
const [activeRequest, setActiveRequest] = useState(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const disabled = !!activeRequest;
|
||||
|
||||
const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
|
||||
|
||||
const [dropZone, setDropZone] = useState(false);
|
||||
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
|
||||
|
||||
const open = !!props.openMode;
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragenter', () => {
|
||||
props.onDragEnter();
|
||||
setDropZone(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(props.baseUrl);
|
||||
setTopic(props.topic);
|
||||
setShowTopicUrl(!props.baseUrl || !props.topic);
|
||||
setMessageFocused(!!props.topic); // Focus message only if topic is set
|
||||
}, [props.baseUrl, props.topic]);
|
||||
|
||||
useEffect(() => {
|
||||
const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
|
||||
setSendButtonEnabled(valid);
|
||||
}, [baseUrl, topic, attachFileError]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessage(props.message);
|
||||
}, [props.message]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const url = new URL(topicUrl(baseUrl, topic));
|
||||
if (title.trim()) {
|
||||
url.searchParams.append("title", title.trim());
|
||||
}
|
||||
if (tags.trim()) {
|
||||
url.searchParams.append("tags", tags.trim());
|
||||
}
|
||||
if (priority && priority !== 3) {
|
||||
url.searchParams.append("priority", priority.toString());
|
||||
}
|
||||
if (clickUrl.trim()) {
|
||||
url.searchParams.append("click", clickUrl.trim());
|
||||
}
|
||||
if (attachUrl.trim()) {
|
||||
url.searchParams.append("attach", attachUrl.trim());
|
||||
}
|
||||
if (filename.trim()) {
|
||||
url.searchParams.append("filename", filename.trim());
|
||||
}
|
||||
if (email.trim()) {
|
||||
url.searchParams.append("email", email.trim());
|
||||
}
|
||||
if (delay.trim()) {
|
||||
url.searchParams.append("delay", delay.trim());
|
||||
}
|
||||
if (attachFile && message.trim()) {
|
||||
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
|
||||
}
|
||||
const body = (attachFile) ? attachFile : message;
|
||||
try {
|
||||
const user = await userManager.get(baseUrl);
|
||||
const headers = maybeWithBasicAuth({}, user);
|
||||
const progressFn = (ev) => {
|
||||
if (ev.loaded > 0 && ev.total > 0) {
|
||||
setStatus(t("publish_dialog_progress_uploading_detail", {
|
||||
loaded: formatBytes(ev.loaded),
|
||||
total: formatBytes(ev.total),
|
||||
percent: Math.round(ev.loaded * 100.0 / ev.total)
|
||||
}));
|
||||
} else {
|
||||
setStatus(t("publish_dialog_progress_uploading"));
|
||||
}
|
||||
};
|
||||
const request = api.publishXHR(url, body, headers, progressFn);
|
||||
setActiveRequest(request);
|
||||
await request;
|
||||
if (!publishAnother) {
|
||||
props.onClose();
|
||||
} else {
|
||||
setStatus(t("publish_dialog_message_published"));
|
||||
setActiveRequest(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
|
||||
setActiveRequest(null);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAttachmentLimits = async (file) => {
|
||||
try {
|
||||
const stats = await api.userStats(baseUrl);
|
||||
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
|
||||
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
|
||||
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
|
||||
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
|
||||
if (fileSizeLimitReached && quotaReached) {
|
||||
return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", {
|
||||
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||
remainingBytes: formatBytes(remainingBytes)
|
||||
}));
|
||||
} else if (fileSizeLimitReached) {
|
||||
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) }));
|
||||
} else if (quotaReached) {
|
||||
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) }));
|
||||
}
|
||||
setAttachFileError("");
|
||||
} catch (e) {
|
||||
console.log(`[SendDialog] Retrieving attachment limits failed`, e);
|
||||
setAttachFileError(""); // Reset error (rely on server-side checking)
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachFileClick = () => {
|
||||
attachFileInput.current.click();
|
||||
};
|
||||
|
||||
const handleAttachFileChanged = async (ev) => {
|
||||
await updateAttachFile(ev.target.files[0]);
|
||||
};
|
||||
|
||||
const handleAttachFileDrop = async (ev) => {
|
||||
ev.preventDefault();
|
||||
setDropZone(false);
|
||||
await updateAttachFile(ev.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const updateAttachFile = async (file) => {
|
||||
setAttachFile(file);
|
||||
setFilename(file.name);
|
||||
props.onResetOpenMode();
|
||||
await checkAttachmentLimits(file);
|
||||
};
|
||||
|
||||
const handleAttachFileDragLeave = () => {
|
||||
setDropZone(false);
|
||||
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
|
||||
props.onClose(); // Only close dialog if it was not open before dragging file in
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiClick = (ev) => {
|
||||
setEmojiPickerAnchorEl(ev.currentTarget);
|
||||
};
|
||||
|
||||
const handleEmojiPick = (emoji) => {
|
||||
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
|
||||
};
|
||||
|
||||
const handleEmojiClose = () => {
|
||||
setEmojiPickerAnchorEl(null);
|
||||
};
|
||||
|
||||
const priorities = {
|
||||
1: { label: t("publish_dialog_priority_min"), file: priority1 },
|
||||
2: { label: t("publish_dialog_priority_low"), file: priority2 },
|
||||
3: { label: t("publish_dialog_priority_default"), file: priority3 },
|
||||
4: { label: t("publish_dialog_priority_high"), file: priority4 },
|
||||
5: { label: t("publish_dialog_priority_max"), file: priority5 }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{dropZone && <DropArea
|
||||
onDrop={handleAttachFileDrop}
|
||||
onDragLeave={handleAttachFileDragLeave}/>
|
||||
}
|
||||
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{dropZone && <DropBox/>}
|
||||
{showTopicUrl &&
|
||||
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} onClose={() => {
|
||||
setBaseUrl(props.baseUrl);
|
||||
setTopic(props.topic);
|
||||
setShowTopicUrl(false);
|
||||
}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_base_url_label")}
|
||||
placeholder={t("publish_dialog_base_url_placeholder")}
|
||||
value={baseUrl}
|
||||
onChange={ev => setBaseUrl(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1, marginRight: 1}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_topic_label")}
|
||||
placeholder={t("publish_dialog_topic_placeholder")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
variant="standard"
|
||||
autoFocus={!messageFocused}
|
||||
sx={{flexGrow: 1}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_title_label")}
|
||||
placeholder={t("publish_dialog_title_placeholder")}
|
||||
value={title}
|
||||
onChange={ev => setTitle(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_message_label")}
|
||||
placeholder={t("publish_dialog_message_placeholder")}
|
||||
value={message}
|
||||
onChange={ev => setMessage(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
variant="standard"
|
||||
rows={5}
|
||||
autoFocus={messageFocused}
|
||||
fullWidth
|
||||
multiline
|
||||
/>
|
||||
<div style={{display: 'flex'}}>
|
||||
<EmojiPicker
|
||||
anchorEl={emojiPickerAnchorEl}
|
||||
onEmojiPick={handleEmojiPick}
|
||||
onClose={handleEmojiClose}
|
||||
/>
|
||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick}>
|
||||
<InsertEmoticonIcon/>
|
||||
</DialogIconButton>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_tags_label")}
|
||||
placeholder={t("publish_dialog_tags_placeholder")}
|
||||
value={tags}
|
||||
onChange={ev => setTags(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1, marginRight: 1}}
|
||||
/>
|
||||
<FormControl
|
||||
variant="standard"
|
||||
margin="dense"
|
||||
sx={{minWidth: 170, maxWidth: 300, flexGrow: 1}}
|
||||
>
|
||||
<InputLabel/>
|
||||
<Select
|
||||
label={t("publish_dialog_priority_label")}
|
||||
margin="dense"
|
||||
value={priority}
|
||||
onChange={(ev) => setPriority(ev.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{[5,4,3,2,1].map(priority =>
|
||||
<MenuItem key={`priorityMenuItem${priority}`} value={priority}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={priorities[priority].file} style={{marginRight: "8px"}}/>
|
||||
<div>{priorities[priority].label}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
{showClickUrl &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
setClickUrl("");
|
||||
setShowClickUrl(false);
|
||||
}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_click_label")}
|
||||
placeholder={t("publish_dialog_click_placeholder")}
|
||||
value={clickUrl}
|
||||
onChange={ev => setClickUrl(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="url"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showEmail &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
setEmail("");
|
||||
setShowEmail(false);
|
||||
}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_email_label")}
|
||||
placeholder={t("publish_dialog_email_placeholder")}
|
||||
value={email}
|
||||
onChange={ev => setEmail(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="email"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showAttachUrl &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
setAttachUrl("");
|
||||
setFilename("");
|
||||
setFilenameEdited(false);
|
||||
setShowAttachUrl(false);
|
||||
}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_attach_label")}
|
||||
placeholder={t("publish_dialog_attach_placeholder")}
|
||||
value={attachUrl}
|
||||
onChange={ev => {
|
||||
const url = ev.target.value;
|
||||
setAttachUrl(url);
|
||||
if (!filenameEdited) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const parts = u.pathname.split("/");
|
||||
if (parts.length > 0) {
|
||||
setFilename(parts[parts.length-1]);
|
||||
}
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 5, marginRight: 1}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_filename_label")}
|
||||
placeholder={t("publish_dialog_filename_placeholder")}
|
||||
value={filename}
|
||||
onChange={ev => {
|
||||
setFilename(ev.target.value);
|
||||
setFilenameEdited(true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
<input
|
||||
type="file"
|
||||
ref={attachFileInput}
|
||||
onChange={handleAttachFileChanged}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{showAttachFile && <AttachmentBox
|
||||
file={attachFile}
|
||||
filename={filename}
|
||||
disabled={disabled}
|
||||
error={attachFileError}
|
||||
onChangeFilename={(f) => setFilename(f)}
|
||||
onClose={() => {
|
||||
setAttachFile(null);
|
||||
setAttachFileError("");
|
||||
setFilename("");
|
||||
}}
|
||||
/>}
|
||||
{showDelay &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
setDelay("");
|
||||
setShowDelay(false);
|
||||
}}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("publish_dialog_delay_label")}
|
||||
placeholder={t("publish_dialog_delay_placeholder", {
|
||||
unixTimestamp: "1649029748",
|
||||
relativeTime: "30m",
|
||||
naturalLanguage: "tomorrow, 9am"
|
||||
})}
|
||||
value={delay}
|
||||
onChange={ev => setDelay(ev.target.value)}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
|
||||
{t("publish_dialog_other_features")}
|
||||
</Typography>
|
||||
<div>
|
||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
</div>
|
||||
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
|
||||
<Trans
|
||||
i18nKey="publish_dialog_details_examples_description"
|
||||
components={{
|
||||
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogFooter status={status}>
|
||||
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
|
||||
{!activeRequest &&
|
||||
<>
|
||||
<FormControlLabel
|
||||
label={t("publish_dialog_checkbox_publish_another")}
|
||||
sx={{marginRight: 2}}
|
||||
control={
|
||||
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
|
||||
} />
|
||||
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
|
||||
</>
|
||||
}
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = (props) => {
|
||||
return (
|
||||
<div style={{display: 'flex'}}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClosableRow = (props) => {
|
||||
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
|
||||
return (
|
||||
<Row>
|
||||
{props.children}
|
||||
{closable && <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const DialogIconButton = (props) => {
|
||||
const sx = props.sx || {};
|
||||
return (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="large"
|
||||
edge="start"
|
||||
sx={{height: "45px", marginTop: "17px", ...sx}}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.children}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentBox = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const file = props.file;
|
||||
return (
|
||||
<>
|
||||
<Typography variant="body1" sx={{marginTop: 2}}>
|
||||
{t("publish_dialog_attached_file_title")}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: 0.5,
|
||||
borderRadius: '4px',
|
||||
}}>
|
||||
<AttachmentIcon type={file.type}/>
|
||||
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
|
||||
<ExpandingTextField
|
||||
minWidth={140}
|
||||
variant="body2"
|
||||
placeholder={t("publish_dialog_attached_file_filename_placeholder")}
|
||||
value={props.filename}
|
||||
onChange={(ev) => props.onChangeFilename(ev.target.value)}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<br/>
|
||||
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
||||
{formatBytes(file.size)}
|
||||
{props.error &&
|
||||
<Typography component="span" sx={{ color: 'error.main' }}>
|
||||
{" "}({props.error})
|
||||
</Typography>
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpandingTextField = (props) => {
|
||||
const invisibleFieldRef = useRef();
|
||||
const [textWidth, setTextWidth] = useState(props.minWidth);
|
||||
const determineTextWidth = () => {
|
||||
const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
|
||||
if (!boundingRect) {
|
||||
return props.minWidth;
|
||||
}
|
||||
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
|
||||
};
|
||||
useEffect(() => {
|
||||
setTextWidth(determineTextWidth() + 5);
|
||||
}, [props.value]);
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
ref={invisibleFieldRef}
|
||||
component="span"
|
||||
variant={props.variant}
|
||||
sx={{position: "absolute", left: "-200%"}}
|
||||
>
|
||||
{props.value}
|
||||
</Typography>
|
||||
<TextField
|
||||
margin="dense"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
|
||||
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
|
||||
inputProps={{ style: { paddingBottom: 0, paddingTop: 0 } }}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
const DropArea = (props) => {
|
||||
const allowDrag = (ev) => {
|
||||
// This is where we could disallow certain files to be dragged in.
|
||||
// For now we allow all files.
|
||||
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10002,
|
||||
}}
|
||||
onDrop={props.onDrop}
|
||||
onDragEnter={allowDrag}
|
||||
onDragOver={allowDrag}
|
||||
onDragLeave={props.onDragLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DropBox = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10000,
|
||||
backgroundColor: "#ffffffbb"
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
border: '3px dashed #ccc',
|
||||
borderRadius: '5px',
|
||||
left: "40px",
|
||||
top: "40px",
|
||||
right: "40px",
|
||||
bottom: "40px",
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
PublishDialog.OPEN_MODE_DEFAULT = "default";
|
||||
PublishDialog.OPEN_MODE_DRAG = "drag";
|
||||
|
||||
export default PublishDialog;
|
||||
@@ -3,7 +3,6 @@ import {useState} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -11,10 +10,11 @@ import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/mate
|
||||
import theme from "./theme";
|
||||
import api from "../app/Api";
|
||||
import {topicUrl, validTopic, validUrl} from "../app/utils";
|
||||
import Box from "@mui/material/Box";
|
||||
import userManager from "../app/UserManager";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import poller from "../app/Poller";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
const publicBaseUrl = "https://ntfy.sh";
|
||||
|
||||
@@ -52,6 +52,7 @@ const SubscribeDialog = (props) => {
|
||||
};
|
||||
|
||||
const SubscribePage = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const baseUrl = (anotherServerVisible) ? props.baseUrl : window.location.origin;
|
||||
@@ -61,12 +62,12 @@ const SubscribePage = (props) => {
|
||||
.filter(s => s !== window.location.origin);
|
||||
const handleSubscribe = async () => {
|
||||
const user = await userManager.get(baseUrl); // May be undefined
|
||||
const username = (user) ? user.username : "anonymous";
|
||||
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
|
||||
const success = await api.auth(baseUrl, topic, user);
|
||||
if (!success) {
|
||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||
if (user) {
|
||||
setErrorText(`User ${username} not authorized`);
|
||||
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
||||
return;
|
||||
} else {
|
||||
props.onNeedsLogin();
|
||||
@@ -91,17 +92,16 @@ const SubscribePage = (props) => {
|
||||
})();
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>Subscribe to topic</DialogTitle>
|
||||
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Topics may not be password-protected, so choose a name that's not easy to guess.
|
||||
Once subscribed, you can PUT/POST notifications.
|
||||
{t("subscribe_dialog_subscribe_description")}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="topic"
|
||||
placeholder="Topic name, e.g. phil_alerts"
|
||||
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
|
||||
inputProps={{ maxLength: 64 }}
|
||||
value={props.topic}
|
||||
onChange={ev => props.setTopic(ev.target.value)}
|
||||
@@ -112,7 +112,7 @@ const SubscribePage = (props) => {
|
||||
<FormControlLabel
|
||||
sx={{pt: 1}}
|
||||
control={<Checkbox onChange={handleUseAnotherChanged}/>}
|
||||
label="Use another server" />
|
||||
label={t("subscribe_dialog_subscribe_use_another_label")} />
|
||||
{anotherServerVisible && <Autocomplete
|
||||
freeSolo
|
||||
options={existingBaseUrls}
|
||||
@@ -125,14 +125,15 @@ const SubscribePage = (props) => {
|
||||
/>}
|
||||
</DialogContent>
|
||||
<DialogFooter status={errorText}>
|
||||
<Button onClick={props.onCancel}>Cancel</Button>
|
||||
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>Subscribe</Button>
|
||||
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
|
||||
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginPage = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errorText, setErrorText] = useState("");
|
||||
@@ -143,7 +144,7 @@ const LoginPage = (props) => {
|
||||
const success = await api.auth(baseUrl, topic, user);
|
||||
if (!success) {
|
||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||
setErrorText(`User ${username} not authorized`);
|
||||
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
||||
return;
|
||||
}
|
||||
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
|
||||
@@ -152,17 +153,16 @@ const LoginPage = (props) => {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>Login required</DialogTitle>
|
||||
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This topic is password-protected. Please enter username and
|
||||
password to subscribe.
|
||||
{t("subscribe_dialog_login_description")}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="username"
|
||||
label="Username, e.g. phil"
|
||||
label={t("subscribe_dialog_login_username_label")}
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
type="text"
|
||||
@@ -172,7 +172,7 @@ const LoginPage = (props) => {
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="password"
|
||||
label="Password"
|
||||
label={t("subscribe_dialog_login_password_label")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
@@ -181,34 +181,11 @@ const LoginPage = (props) => {
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter status={errorText}>
|
||||
<Button onClick={props.onBack}>Back</Button>
|
||||
<Button onClick={handleLogin}>Login</Button>
|
||||
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
|
||||
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DialogFooter = (props) => {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: '24px',
|
||||
paddingTop: '8px 24px',
|
||||
paddingBottom: '8px 24px',
|
||||
}}>
|
||||
<DialogContentText sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '8px',
|
||||
}}>
|
||||
{props.status}
|
||||
</DialogContentText>
|
||||
<DialogActions>
|
||||
{props.children}
|
||||
</DialogActions>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscribeDialog;
|
||||
|
||||
@@ -6,6 +6,7 @@ import notifier from "../app/Notifier";
|
||||
import routes from "./routes";
|
||||
import connectionManager from "../app/ConnectionManager";
|
||||
import poller from "../app/Poller";
|
||||
import pruner from "../app/Pruner";
|
||||
|
||||
/**
|
||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||
@@ -67,29 +68,13 @@ export const useAutoSubscribe = (subscriptions, selected) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate the 'topics' item in localStorage to the subscriptionManager. This is only done once to migrate away
|
||||
* from the old web UI.
|
||||
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
|
||||
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
|
||||
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
|
||||
*/
|
||||
export const useLocalStorageMigration = () => {
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
export const useBackgroundProcesses = () => {
|
||||
useEffect(() => {
|
||||
if (hasRun) {
|
||||
return;
|
||||
}
|
||||
const topicsStr = localStorage.getItem("topics");
|
||||
if (topicsStr) {
|
||||
const topics = JSON.parse(topicsStr).filter(topic => topic !== "");
|
||||
if (topics.length > 0) {
|
||||
(async () => {
|
||||
for (const topic of topics) {
|
||||
const baseUrl = window.location.origin;
|
||||
const subscription = await subscriptionManager.add(baseUrl, topic);
|
||||
poller.pollInBackground(subscription); // Dangle!
|
||||
}
|
||||
localStorage.removeItem("topics");
|
||||
})();
|
||||
}
|
||||
}
|
||||
setHasRun(true);
|
||||
poller.startWorker();
|
||||
pruner.startWorker();
|
||||
}, []);
|
||||
}
|
||||
|
||||
29
web/src/components/i18n.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import i18n from 'i18next';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
// Translations using i18next
|
||||
// - Options: https://www.i18next.com/overview/configuration-options
|
||||
// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector
|
||||
// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend
|
||||
//
|
||||
// See example project here:
|
||||
// https://github.com/i18next/react-i18next/tree/master/example/react
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: true,
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
backend: {
|
||||
loadPath: '/static/langs/{{lng}}.json',
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -13,4 +13,5 @@ const routes = {
|
||||
return `/${subscription.topic}`;
|
||||
}
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
1
web/src/img/priority-3.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M4.882 16.057c-.33-.114-.636-.42-.795-.797-.084-.202-.1-.292-.1-.578-.001-.302.011-.366.116-.6a1.44 1.44 0 01.652-.704l.205-.106h14.077l.205.106c.756.39 1.01 1.376.548 2.128a1.217 1.217 0 01-.588.515l-.201.091-6.985-.001c-5.641-.002-7.013-.012-7.134-.054zM4.858 10.595c-.33-.114-.635-.42-.794-.797-.085-.201-.1-.292-.101-.578 0-.302.012-.366.116-.6a1.44 1.44 0 01.653-.704l.205-.106h14.076l.205.106c.757.39 1.01 1.377.548 2.128a1.217 1.217 0 01-.587.515l-.202.092-6.984-.002c-5.642-.002-7.013-.012-7.135-.054z" fill="#0091ff"/></svg>
|
||||
|
After Width: | Height: | Size: 605 B |
@@ -1,8 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './components/App';
|
||||
|
||||
ReactDOM.render(
|
||||
<App />,
|
||||
document.querySelector('#root')
|
||||
);
|
||||
const root = createRoot(document.querySelector('#root'));
|
||||
root.render(<App />);
|
||||
|
||||