Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749e334396 | ||
|
|
78a681f277 | ||
|
|
3f96fad7ce | ||
|
|
83bb9951b0 | ||
|
|
4a5f34801a | ||
|
|
2cd7839da3 | ||
|
|
35ddcb27f0 | ||
|
|
328aca48ab | ||
|
|
4eba641ec3 | ||
|
|
f2d4af04e3 | ||
|
|
d44ee2bbf6 | ||
|
|
6f07944442 | ||
|
|
7716b1e81e | ||
|
|
8914809775 | ||
|
|
dcb5531038 | ||
|
|
0c666f96b1 | ||
|
|
d9c3c20350 | ||
|
|
73349cd423 | ||
|
|
b3667a916b | ||
|
|
6791c7395b | ||
|
|
aba7e86cbc | ||
|
|
12f973f61b | ||
|
|
f98743dd9b | ||
|
|
2c8b258ae7 | ||
|
|
00520a7a38 | ||
|
|
611894fd05 | ||
|
|
aabae53e5d | ||
|
|
85cf7bb687 | ||
|
|
44b9358c60 | ||
|
|
ee6188d100 | ||
|
|
2bdae49425 | ||
|
|
9814a9f792 | ||
|
|
5aedfd3898 | ||
|
|
59ec2de8bd | ||
|
|
d154d3936d | ||
|
|
5125aac91c | ||
|
|
7ff34364a3 | ||
|
|
3d0d70dc17 | ||
|
|
62512b7a1a | ||
|
|
c5a1344e8a | ||
|
|
402b05a27b | ||
|
|
b67d9fc85d | ||
|
|
3e121f5d3c | ||
|
|
b6426f0417 | ||
|
|
59b341dfb8 | ||
|
|
e2834a7c4d | ||
|
|
e0b3068a5e | ||
|
|
2280031a80 | ||
|
|
8f2851e20a | ||
|
|
2eeb7d63a0 | ||
|
|
b20df55b88 | ||
|
|
de1b97bbce | ||
|
|
3b4a4108e5 | ||
|
|
dc1c0ddd4e | ||
|
|
182e21a9c3 | ||
|
|
187c19f3b2 | ||
|
|
d5eff0cd34 | ||
|
|
d4fe2052c7 | ||
|
|
2e92be0f23 | ||
|
|
94b0e6f690 | ||
|
|
202051bbbf | ||
|
|
a693975526 | ||
|
|
4cd4e890fe | ||
|
|
5dc8031ec9 | ||
|
|
03ad5dcff6 | ||
|
|
5f508e1839 | ||
|
|
c5642799df | ||
|
|
5a99fe8ba2 | ||
|
|
ee0f448d86 | ||
|
|
a222f64ee4 | ||
|
|
140daec0d3 | ||
|
|
b409c89d3b | ||
|
|
806893962c | ||
|
|
14d3c5e93e | ||
|
|
37e14b13a4 | ||
|
|
d7fa51be2c | ||
|
|
a3e28e71aa | ||
|
|
35cef8386c | ||
|
|
38072c9cdd | ||
|
|
13d741b89e | ||
|
|
cc90a1af15 | ||
|
|
21fc1245eb | ||
|
|
2511ba7627 | ||
|
|
23547f4504 | ||
|
|
e6f19d050f | ||
|
|
3ec8084450 | ||
|
|
2edb722c0e | ||
|
|
1f75498dca | ||
|
|
ab19c4d688 | ||
|
|
15265d9ef3 | ||
|
|
2839a7228f | ||
|
|
c2036975fa | ||
|
|
7aa0f87376 | ||
|
|
df372d1a7e | ||
|
|
6cd31502e7 | ||
|
|
bade88079f | ||
|
|
20ab05afc8 | ||
|
|
5b10f51af1 | ||
|
|
470d11f442 | ||
|
|
4952f0fbd2 | ||
|
|
0a3292566c | ||
|
|
f4e8ebc053 | ||
|
|
ad77bde8c8 | ||
|
|
5241b29cc6 | ||
|
|
8fcc40942f | ||
|
|
37d4d5d647 | ||
|
|
b67b9e83ff | ||
|
|
4c3dcec19e | ||
|
|
53375ff559 | ||
|
|
53e08988e7 | ||
|
|
d0bbda555f | ||
|
|
207e990798 | ||
|
|
b0a07af28d | ||
|
|
1a8bac7ab1 | ||
|
|
dc03c13642 | ||
|
|
739b20583d | ||
|
|
10ccbc780b | ||
|
|
f971a36ec0 | ||
|
|
3699464947 | ||
|
|
3a3d1262ab | ||
|
|
395a97c0e5 |
@@ -4,7 +4,7 @@ before:
|
|||||||
- go mod tidy
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
-
|
||||||
id: ntfy
|
id: ntfy_amd64
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
@@ -28,9 +28,8 @@ builds:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [7]
|
goarm: [7]
|
||||||
hooks:
|
# No "upx", since it causes random core dumps, see
|
||||||
post:
|
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
- upx "{{ .Path }}" # apt install upx
|
|
||||||
-
|
-
|
||||||
id: ntfy_arm64
|
id: ntfy_arm64
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
@@ -42,9 +41,8 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm64]
|
goarch: [arm64]
|
||||||
hooks:
|
# No "upx", since it causes random core dumps, see
|
||||||
post:
|
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
- upx "{{ .Path }}" # apt install upx
|
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
-
|
||||||
package_name: ntfy
|
package_name: ntfy
|
||||||
@@ -59,12 +57,12 @@ nfpms:
|
|||||||
contents:
|
contents:
|
||||||
- src: server/server.yml
|
- src: server/server.yml
|
||||||
dst: /etc/ntfy/server.yml
|
dst: /etc/ntfy/server.yml
|
||||||
type: config
|
type: "config|noreplace"
|
||||||
- src: server/ntfy.service
|
- src: server/ntfy.service
|
||||||
dst: /lib/systemd/system/ntfy.service
|
dst: /lib/systemd/system/ntfy.service
|
||||||
- src: client/client.yml
|
- src: client/client.yml
|
||||||
dst: /etc/ntfy/client.yml
|
dst: /etc/ntfy/client.yml
|
||||||
type: config
|
type: "config|noreplace"
|
||||||
- src: client/ntfy-client.service
|
- src: client/ntfy-client.service
|
||||||
dst: /lib/systemd/system/ntfy-client.service
|
dst: /lib/systemd/system/ntfy-client.service
|
||||||
- dst: /var/cache/ntfy
|
- dst: /var/cache/ntfy
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ FROM alpine
|
|||||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||||
|
|
||||||
COPY ntfy /usr/bin
|
COPY ntfy /usr/bin
|
||||||
|
|
||||||
|
EXPOSE 80/tcp
|
||||||
ENTRYPOINT ["ntfy"]
|
ENTRYPOINT ["ntfy"]
|
||||||
|
|||||||
193
Makefile
193
Makefile
@@ -3,55 +3,87 @@ VERSION := $(shell git describe --tag)
|
|||||||
.PHONY:
|
.PHONY:
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Typical commands:"
|
@echo "Typical commands (more see below):"
|
||||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
@echo " make build - Build web app, documentation and server/client (sloowwww)"
|
||||||
@echo " make fmt build-snapshot install - Build latest and install to local system"
|
@echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||||
|
@echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
||||||
|
@echo " make web - Build the web app"
|
||||||
|
@echo " make docs - Build the documentation"
|
||||||
|
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||||
|
@echo
|
||||||
|
@echo "Build everything:"
|
||||||
|
@echo " make build - Build web app, documentation and server/client"
|
||||||
|
@echo " make clean - Clean build/dist folders"
|
||||||
|
@echo
|
||||||
|
@echo "Build server & client (not release version):"
|
||||||
|
@echo " make server - Build server & client (all architectures)"
|
||||||
|
@echo " make server-amd64 - Build server & client (amd64 only)"
|
||||||
|
@echo " make server-armv7 - Build server & client (armv7 only)"
|
||||||
|
@echo " make server-arm64 - Build server & client (arm64 only)"
|
||||||
|
@echo
|
||||||
|
@echo "Build web app:"
|
||||||
|
@echo " make web - Build the web app"
|
||||||
|
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||||
|
@echo " make web-build - Actually build the web app"
|
||||||
|
@echo
|
||||||
|
@echo "Build documentation:"
|
||||||
|
@echo " make docs - Build the documentation"
|
||||||
|
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||||
|
@echo " make docs-build - Actually build the documentation"
|
||||||
@echo
|
@echo
|
||||||
@echo "Test/check:"
|
@echo "Test/check:"
|
||||||
@echo " make test - Run tests"
|
@echo " make test - Run tests"
|
||||||
@echo " make race - Run tests with -race flag"
|
@echo " make race - Run tests with -race flag"
|
||||||
@echo " make coverage - Run tests and show coverage"
|
@echo " make coverage - Run tests and show coverage"
|
||||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||||
@echo
|
@echo
|
||||||
@echo "Lint/format:"
|
@echo "Lint/format:"
|
||||||
@echo " make fmt - Run 'go fmt'"
|
@echo " make fmt - Run 'go fmt'"
|
||||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||||
@echo " make vet - Run 'go vet'"
|
@echo " make vet - Run 'go vet'"
|
||||||
@echo " make lint - Run 'golint'"
|
@echo " make lint - Run 'golint'"
|
||||||
@echo " make staticcheck - Run 'staticcheck'"
|
@echo " make staticcheck - Run 'staticcheck'"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build:"
|
@echo "Releasing:"
|
||||||
@echo " make build - Build"
|
@echo " make release - Create a release"
|
||||||
@echo " make build-snapshot - Build snapshot"
|
@echo " make release-snapshot - Create a test release"
|
||||||
@echo " make build-simple - Build (using go build, without goreleaser)"
|
|
||||||
@echo " make clean - Clean build folder"
|
|
||||||
@echo
|
|
||||||
@echo "Releasing (requires goreleaser):"
|
|
||||||
@echo " make release - Create a release"
|
|
||||||
@echo " make release-snapshot - Create a test release"
|
|
||||||
@echo
|
@echo
|
||||||
@echo "Install locally (requires sudo):"
|
@echo "Install locally (requires sudo):"
|
||||||
@echo " make install - Copy binary from dist/ to /usr/bin"
|
@echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-deb - Install .deb from dist/"
|
@echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-lint - Install golint"
|
@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-armv7 - Install .deb from dist/ (armv7 only)"
|
||||||
|
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||||
|
|
||||||
|
|
||||||
|
# Building everything
|
||||||
|
|
||||||
|
clean: .PHONY
|
||||||
|
rm -rf dist build server/docs server/site
|
||||||
|
|
||||||
|
build: web docs server
|
||||||
|
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
|
docs: docs-deps docs-build
|
||||||
|
|
||||||
docs-deps: .PHONY
|
docs-deps: .PHONY
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
docs: docs-deps
|
docs-build: .PHONY
|
||||||
mkdocs build
|
mkdocs build
|
||||||
|
|
||||||
|
|
||||||
# Web app
|
# Web app
|
||||||
|
|
||||||
|
web: web-deps web-build
|
||||||
|
|
||||||
web-deps:
|
web-deps:
|
||||||
cd web \
|
cd web && npm install
|
||||||
&& npm install \
|
# If this fails for .svg files, optimizes them with svgo
|
||||||
&& node_modules/svgo/bin/svgo src/img/*.svg
|
|
||||||
|
|
||||||
web-build:
|
web-build:
|
||||||
cd web \
|
cd web \
|
||||||
@@ -63,7 +95,37 @@ web-build:
|
|||||||
../server/site/config.js \
|
../server/site/config.js \
|
||||||
../server/site/asset-manifest.json
|
../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-armv7: server-deps-static-sites server-deps-gcc-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-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-armv7:
|
||||||
|
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||||
|
|
||||||
|
server-deps-gcc-arm64:
|
||||||
|
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||||
|
|
||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
@@ -114,56 +176,51 @@ staticcheck: .PHONY
|
|||||||
rm -rf build/staticcheck
|
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
|
# Releasing targets
|
||||||
|
|
||||||
|
release: release-deps
|
||||||
|
goreleaser release --rm-dist --debug
|
||||||
|
|
||||||
|
release-snapshot: release-deps
|
||||||
|
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
||||||
|
|
||||||
|
release-deps: clean server-deps release-check-tags docs web check
|
||||||
|
|
||||||
release-check-tags:
|
release-check-tags:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||||
exit 1;\
|
exit 1;\
|
||||||
fi
|
fi
|
||||||
|
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
|
||||||
release: build-deps release-check-tags check
|
echo "ERROR: Must update docs/releases.md with latest tag first.";\
|
||||||
goreleaser release --rm-dist --debug
|
exit 1;\
|
||||||
|
fi
|
||||||
release-snapshot: build-deps
|
|
||||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
|
||||||
|
|
||||||
|
|
||||||
# Installing targets
|
# Installing targets
|
||||||
|
|
||||||
install:
|
install-amd64: remove-binary
|
||||||
sudo rm -f /usr/bin/ntfy
|
sudo cp -a dist/ntfy_amd64_linux_amd64/ntfy /usr/bin/ntfy
|
||||||
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
|
|
||||||
|
|
||||||
install-deb:
|
install-armv7: remove-binary
|
||||||
|
sudo cp -a dist/ntfy_armv7_linux_armv7/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-armv7-deb: purge-package
|
||||||
|
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||||
|
|
||||||
|
install-arm64-deb: purge-package
|
||||||
|
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||||
|
|
||||||
|
purge-package:
|
||||||
sudo systemctl stop ntfy || true
|
sudo systemctl stop ntfy || true
|
||||||
sudo apt-get purge ntfy || true
|
sudo apt-get purge ntfy || true
|
||||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -1,4 +1,4 @@
|
|||||||

|

|
||||||
|
|
||||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||||
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
||||||
@@ -18,11 +18,11 @@ I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [op
|
|||||||
too.
|
too.
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="server/static/img/screenshot-curl.png" height="180">
|
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||||
<img src="server/static/img/screenshot-web-detail.png" height="180">
|
<img src="web/public/static/img/screenshot-web-detail.png" height="180">
|
||||||
<img src="server/static/img/screenshot-phone-main.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-main.jpg" height="180">
|
||||||
<img src="server/static/img/screenshot-phone-detail.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-detail.jpg" height="180">
|
||||||
<img src="server/static/img/screenshot-phone-notification.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-notification.jpg" height="180">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## **[Documentation](https://ntfy.sh/docs/)**
|
## **[Documentation](https://ntfy.sh/docs/)**
|
||||||
@@ -34,7 +34,14 @@ too.
|
|||||||
[Building](https://ntfy.sh/docs/develop/)
|
[Building](https://ntfy.sh/docs/develop/)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
I welcome any and all contributions. Just create a PR or an issue.
|
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
|
||||||
|
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
|
||||||
|
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
||||||
|
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||||
|
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Contact me
|
## Contact me
|
||||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ func New() *cli.App {
|
|||||||
Reader: os.Stdin,
|
Reader: os.Stdin,
|
||||||
Writer: os.Stdout,
|
Writer: os.Stdout,
|
||||||
ErrWriter: os.Stderr,
|
ErrWriter: os.Stderr,
|
||||||
Action: execMainApp,
|
|
||||||
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
|
|
||||||
Flags: flagsServe, // DEPRECATED, see deprecation notice
|
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
// Server commands
|
// Server commands
|
||||||
cmdServe,
|
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
|
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||||
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
||||||
|
|||||||
16
cmd/user.go
16
cmd/user.go
@@ -25,7 +25,6 @@ var cmdUser = &cli.Command{
|
|||||||
Aliases: []string{"a"},
|
Aliases: []string{"a"},
|
||||||
Usage: "Adds a new user",
|
Usage: "Adds a new user",
|
||||||
UsageText: "ntfy user add [--role=admin|user] USERNAME",
|
UsageText: "ntfy user add [--role=admin|user] USERNAME",
|
||||||
Before: inheritRootReaderFunc,
|
|
||||||
Action: execUserAdd,
|
Action: execUserAdd,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
||||||
@@ -46,7 +45,6 @@ Examples:
|
|||||||
Aliases: []string{"del", "rm"},
|
Aliases: []string{"del", "rm"},
|
||||||
Usage: "Removes a user",
|
Usage: "Removes a user",
|
||||||
UsageText: "ntfy user remove USERNAME",
|
UsageText: "ntfy user remove USERNAME",
|
||||||
Before: inheritRootReaderFunc,
|
|
||||||
Action: execUserDel,
|
Action: execUserDel,
|
||||||
Description: `Remove a user from the ntfy user database.
|
Description: `Remove a user from the ntfy user database.
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@ Example:
|
|||||||
Aliases: []string{"chp"},
|
Aliases: []string{"chp"},
|
||||||
Usage: "Changes a user's password",
|
Usage: "Changes a user's password",
|
||||||
UsageText: "ntfy user change-pass USERNAME",
|
UsageText: "ntfy user change-pass USERNAME",
|
||||||
Before: inheritRootReaderFunc,
|
|
||||||
Action: execUserChangePass,
|
Action: execUserChangePass,
|
||||||
Description: `Change the password for the given user.
|
Description: `Change the password for the given user.
|
||||||
|
|
||||||
@@ -75,7 +72,6 @@ Example:
|
|||||||
Aliases: []string{"chr"},
|
Aliases: []string{"chr"},
|
||||||
Usage: "Changes the role of a user",
|
Usage: "Changes the role of a user",
|
||||||
UsageText: "ntfy user change-role USERNAME ROLE",
|
UsageText: "ntfy user change-role USERNAME ROLE",
|
||||||
Before: inheritRootReaderFunc,
|
|
||||||
Action: execUserChangeRole,
|
Action: execUserChangeRole,
|
||||||
Description: `Change the role for the given user to admin or user.
|
Description: `Change the role for the given user to admin or user.
|
||||||
|
|
||||||
@@ -97,7 +93,6 @@ Example:
|
|||||||
Name: "list",
|
Name: "list",
|
||||||
Aliases: []string{"l"},
|
Aliases: []string{"l"},
|
||||||
Usage: "Shows a list of users",
|
Usage: "Shows a list of users",
|
||||||
Before: inheritRootReaderFunc,
|
|
||||||
Action: execUserList,
|
Action: execUserList,
|
||||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
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"}),
|
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`:
|
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
||||||
|
|
||||||
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
||||||
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
|
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)
|
||||||
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
||||||
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
||||||
accepted (which may obviously be a spam problem).
|
accepted (which may obviously be a spam problem).
|
||||||
@@ -369,6 +369,42 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
|||||||
<figcaption>DNS records for incoming mail</figcaption>
|
<figcaption>DNS records for incoming mail</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
You can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g.
|
||||||
|
`email.txt`
|
||||||
|
|
||||||
|
```
|
||||||
|
EHLO example.com
|
||||||
|
MAIL FROM: phil@example.com
|
||||||
|
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||||
|
DATA
|
||||||
|
Subject: Email for you
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
Hello from 🇩🇪
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
And then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the
|
||||||
|
ntfy server. Read them carefully.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat email.txt | nc -N ntfy.sh 25
|
||||||
|
220 ntfy.sh ESMTP Service Ready
|
||||||
|
250-Hello example.com
|
||||||
|
...
|
||||||
|
250 2.0.0 Roger, accepting mail from <phil@example.com>
|
||||||
|
250 2.0.0 I'll make sure <ntfy-mytopic@ntfy.sh> gets this
|
||||||
|
```
|
||||||
|
|
||||||
|
As for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ dig MX ntfy.sh +short
|
||||||
|
10 mx1.ntfy.sh.
|
||||||
|
$ dig A mx1.ntfy.sh +short
|
||||||
|
3.139.215.220
|
||||||
|
```
|
||||||
|
|
||||||
## Behind a proxy (TLS, etc.)
|
## Behind a proxy (TLS, etc.)
|
||||||
!!! warning
|
!!! warning
|
||||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
||||||
@@ -399,8 +435,10 @@ HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encry
|
|||||||
be incredibly helpful.
|
be incredibly helpful.
|
||||||
|
|
||||||
### nginx/Apache2/caddy
|
### nginx/Apache2/caddy
|
||||||
For your convenience, here's a working config that'll help configure things behind a proxy. In this
|
For your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets**
|
||||||
example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
by forwarding the `Connection` and `Upgrade` headers accordingly.
|
||||||
|
|
||||||
|
In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
||||||
or the root domain:
|
or the root domain:
|
||||||
|
|
||||||
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
||||||
|
|||||||
@@ -4,16 +4,28 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
|
|||||||
|
|
||||||
## Active deprecations
|
## Active deprecations
|
||||||
|
|
||||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
### Android app: WebSockets will become the default connection protocol
|
||||||
> since 2022-02-27
|
> 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
|
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.
|
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.
|
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||||
|
|
||||||
|
## Previous deprecations
|
||||||
|
|
||||||
### Running server via `ntfy` (instead of `ntfy serve`)
|
### Running server via `ntfy` (instead of `ntfy serve`)
|
||||||
> since 2021-12-17
|
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||||
|
|
||||||
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
||||||
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
||||||
|
|||||||
301
docs/develop.md
301
docs/develop.md
@@ -1,51 +1,287 @@
|
|||||||
# Building
|
# Development
|
||||||
|
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||||
|
|
||||||
|
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||||
|
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||||
|
|
||||||
## ntfy server
|
## ntfy server
|
||||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy).
|
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||||
To quickly build on amd64, you can use `make build-simple`:
|
server consists of three components:
|
||||||
|
|
||||||
```
|
* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at
|
||||||
git clone git@github.com:binwiederhier/ntfy.git
|
[main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is
|
||||||
cd ntfy
|
in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a
|
||||||
make build-simple
|
[SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires
|
||||||
|
[Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).
|
||||||
|
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
|
||||||
|
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
|
||||||
|
build the docs.
|
||||||
|
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
|
||||||
|
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
|
||||||
|
and install all the 100,000 dependencies (*sigh*).
|
||||||
|
|
||||||
|
All of these components are built and then **baked into one binary**.
|
||||||
|
|
||||||
|
### Navigating the code
|
||||||
|
Code:
|
||||||
|
|
||||||
|
* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client
|
||||||
|
* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`
|
||||||
|
* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic
|
||||||
|
* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`
|
||||||
|
* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`
|
||||||
|
|
||||||
|
Build related:
|
||||||
|
|
||||||
|
* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building
|
||||||
|
* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))
|
||||||
|
* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file
|
||||||
|
* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))
|
||||||
|
* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)
|
||||||
|
|
||||||
|
|
||||||
|
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
|
||||||
|
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
|
||||||
|
|
||||||
|
### Build requirements
|
||||||
|
|
||||||
|
* [Go](https://go.dev/) (required for main server)
|
||||||
|
* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings)
|
||||||
|
* [Make](https://www.gnu.org/software/make/) (required for convenience)
|
||||||
|
* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings)
|
||||||
|
* [GoReleaser](https://goreleaser.com/) (required for a proper main server build)
|
||||||
|
* [Python](https://www.python.org/) (for `pip`, only to build the docs)
|
||||||
|
* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app)
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
|
||||||
|
|
||||||
|
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
||||||
|
``` shell
|
||||||
|
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
|
||||||
|
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
|
||||||
|
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||||
|
go version # verifies that it worked
|
||||||
```
|
```
|
||||||
|
|
||||||
That'll generate a statically linked binary in `dist/ntfy_linux_amd64/ntfy`.
|
Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):
|
||||||
|
``` shell
|
||||||
For all other platforms (including Docker), and for production or other snapshot builds, you should use the amazingly
|
go install github.com/goreleaser/goreleaser@latest
|
||||||
awesome [GoReleaser](https://goreleaser.com/) make targets:
|
goreleaser -v # verifies that it worked
|
||||||
|
|
||||||
```
|
|
||||||
Build:
|
|
||||||
make build - Build
|
|
||||||
make build-snapshot - Build snapshot
|
|
||||||
make build-simple - Build (using go build, without goreleaser)
|
|
||||||
make clean - Clean build folder
|
|
||||||
|
|
||||||
Releasing (requires goreleaser):
|
|
||||||
make release - Create a release
|
|
||||||
make release-snapshot - Create a test release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There are currently no platform-specific make targets, so they will build for all platforms (which may take a while).
|
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||||
|
``` shell
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
npm -v # verifies that it worked
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install a few other things required:
|
||||||
|
``` shell
|
||||||
|
sudo apt install \
|
||||||
|
build-essential \
|
||||||
|
libsqlite3-dev \
|
||||||
|
gcc-arm-linux-gnueabi \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
python3-pip \
|
||||||
|
upx \
|
||||||
|
git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check out code
|
||||||
|
Now check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy):
|
||||||
|
|
||||||
|
=== "via HTTPS"
|
||||||
|
``` shell
|
||||||
|
git clone https://github.com/binwiederhier/ntfy.git
|
||||||
|
cd ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "via SSH"
|
||||||
|
``` shell
|
||||||
|
git clone git@github.com:binwiederhier/ntfy.git
|
||||||
|
cd ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build all the things
|
||||||
|
Now you can finally build everything. There are tons of `make` targets, so maybe just review what's there first
|
||||||
|
by typing `make`:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make
|
||||||
|
Typical commands (more see below):
|
||||||
|
make build - Build web app, documentation and server/client (sloowwww)
|
||||||
|
make server-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||||
|
make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
||||||
|
make web - Build the web app
|
||||||
|
make docs - Build the documentation
|
||||||
|
make check - Run all tests, vetting/formatting checks and linters
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64),
|
||||||
|
you can simply run `make build`:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make build
|
||||||
|
...
|
||||||
|
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
|
||||||
|
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see all the outputs in the `dist/` folder afterwards:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
$ find dist
|
||||||
|
dist
|
||||||
|
dist/metadata.json
|
||||||
|
dist/ntfy_arm64_linux_arm64
|
||||||
|
dist/ntfy_arm64_linux_arm64/ntfy
|
||||||
|
dist/ntfy_armv7_linux_arm_7
|
||||||
|
dist/ntfy_armv7_linux_arm_7/ntfy
|
||||||
|
dist/ntfy_amd64_linux_amd64
|
||||||
|
dist/ntfy_amd64_linux_amd64/ntfy
|
||||||
|
dist/config.yaml
|
||||||
|
dist/artifacts.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
|
||||||
|
use the `make release-snapshot` target:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make release-snapshot
|
||||||
|
...
|
||||||
|
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
|
||||||
|
```
|
||||||
|
|
||||||
|
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||||
|
|
||||||
|
### Build the ntfy binary
|
||||||
|
To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make
|
||||||
|
Build server & client (not release version):
|
||||||
|
make server - Build server & client (all architectures)
|
||||||
|
make server-amd64 - Build server & client (amd64 only)
|
||||||
|
make server-armv7 - Build server & client (armv7 only)
|
||||||
|
make server-arm64 - Build server & client (arm64 only)
|
||||||
|
```
|
||||||
|
|
||||||
|
So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern
|
||||||
|
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary
|
||||||
|
right away:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make server-amd64 install-amd64
|
||||||
|
$ ntfy serve
|
||||||
|
```
|
||||||
|
|
||||||
|
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
||||||
|
`make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ export CGO_ENABLED=1
|
||||||
|
$ make server-deps-static-sites
|
||||||
|
$ go run main.go serve
|
||||||
|
2022/03/18 08:43:55 Listening on :2586[http]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
||||||
|
```
|
||||||
|
$ go run main.go serve
|
||||||
|
server/server.go:85:13: pattern docs: no matching files found
|
||||||
|
```
|
||||||
|
|
||||||
|
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
|
||||||
|
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites`
|
||||||
|
target creates dummy files that ensures that you'll be able to build.
|
||||||
|
|
||||||
|
|
||||||
|
### Build the web app
|
||||||
|
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
||||||
|
is really simple. Just type `make web` and you're in business:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make web
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
|
||||||
|
that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary.
|
||||||
|
|
||||||
|
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
|
||||||
|
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
|
||||||
|
will automatically refresh:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ cd web
|
||||||
|
$ npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the docs
|
||||||
|
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
|
||||||
|
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make docs
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
|
||||||
|
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mkdocs serve
|
||||||
|
INFO - Building documentation...
|
||||||
|
INFO - Cleaning site directory
|
||||||
|
INFO - Documentation built in 5.53 seconds
|
||||||
|
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
|
||||||
|
|
||||||
## Android app
|
## Android app
|
||||||
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
|
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
|
||||||
The Android app has two flavors:
|
The Android app has two flavors:
|
||||||
|
|
||||||
* **Google Play:** The `play` flavor includes Firebase (FCM) and requires a Firebase account
|
* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account
|
||||||
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
|
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
|
||||||
|
|
||||||
|
### Navigating the code
|
||||||
|
* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code
|
||||||
|
* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code
|
||||||
|
* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs
|
||||||
|
* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file
|
||||||
|
|
||||||
|
### IDE/Environment
|
||||||
|
You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/)
|
||||||
|
with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀
|
||||||
|
|
||||||
|
### Check out the code
|
||||||
First check out the repository:
|
First check out the repository:
|
||||||
|
|
||||||
```
|
=== "via HTTPS"
|
||||||
git clone git@github.com:binwiederhier/ntfy-android.git
|
``` shell
|
||||||
cd ntfy-android
|
git clone https://github.com/binwiederhier/ntfy-android.git
|
||||||
```
|
cd ntfy-android
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "via SSH"
|
||||||
|
``` shell
|
||||||
|
git clone git@github.com:binwiederhier/ntfy-android.git
|
||||||
|
cd ntfy-android
|
||||||
|
```
|
||||||
|
|
||||||
Then either follow the steps for building with or without Firebase.
|
Then either follow the steps for building with or without Firebase.
|
||||||
|
|
||||||
### Building without Firebase (F-Droid flavor)
|
### Build F-Droid flavor (no FCM)
|
||||||
|
!!! info
|
||||||
|
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||||
|
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||||
|
|
||||||
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||||
if you're self-hosting the server. Then run:
|
if you're self-hosting the server. Then run:
|
||||||
```
|
```
|
||||||
@@ -56,8 +292,13 @@ if you're self-hosting the server. Then run:
|
|||||||
./gradlew bundleFdroidRelease
|
./gradlew bundleFdroidRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building with Firebase (FCM, Google Play flavor)
|
### Build Play flavor (FCM)
|
||||||
|
!!! info
|
||||||
|
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||||
|
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||||
|
|
||||||
To build your own version with Firebase, you must:
|
To build your own version with Firebase, you must:
|
||||||
|
|
||||||
* Create a Firebase/FCM account
|
* Create a Firebase/FCM account
|
||||||
* Place your account file at `app/google-services.json`
|
* Place your account file at `app/google-services.json`
|
||||||
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||||
|
|||||||
273
docs/examples.md
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
|
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Low disk space alerts
|
||||||
|
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
|
||||||
|
effective.
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
mingigs=10
|
||||||
|
avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }')
|
||||||
|
topicurl=https://ntfy.sh/mytopic
|
||||||
|
|
||||||
|
if [ -n "$avail" ]; then
|
||||||
|
curl \
|
||||||
|
-d "Only $avail GB available on the root disk. Better clean that up." \
|
||||||
|
-H "Title: Low disk space alert on $(hostname)" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
-H "Tags: warning,cd" \
|
||||||
|
$topicurl
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
## Server-sent messages in your web app
|
## Server-sent messages in your web app
|
||||||
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
||||||
web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
|
web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
|
||||||
@@ -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"
|
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Random cronjobs
|
||||||
|
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||||
|
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||||
|
|
||||||
|
``` cron
|
||||||
|
# Check github/ntfy user
|
||||||
|
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||||
|
~
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
|
||||||
|
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||||
|
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||||
|
|
||||||
|
## Node-RED
|
||||||
|
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||||
|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Example: Send a message (click to expand)</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "c956e688cc74ad8e",
|
||||||
|
"type": "http request",
|
||||||
|
"z": "fabdd7a3.4045a",
|
||||||
|
"name": "ntfy.sh",
|
||||||
|
"method": "POST",
|
||||||
|
"ret": "txt",
|
||||||
|
"paytoqs": "ignore",
|
||||||
|
"url": "https://ntfy.sh/mytopic",
|
||||||
|
"tls": "",
|
||||||
|
"persist": false,
|
||||||
|
"proxy": "",
|
||||||
|
"authType": "",
|
||||||
|
"senderr": false,
|
||||||
|
"credentials":
|
||||||
|
{
|
||||||
|
"user": "",
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"x": 590,
|
||||||
|
"y": 3160,
|
||||||
|
"wires":
|
||||||
|
[
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "32ee1eade51fae50",
|
||||||
|
"type": "function",
|
||||||
|
"z": "fabdd7a3.4045a",
|
||||||
|
"name": "data",
|
||||||
|
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 470,
|
||||||
|
"y": 3160,
|
||||||
|
"wires":
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"c956e688cc74ad8e"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b287e59cd2311815",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "fabdd7a3.4045a",
|
||||||
|
"name": "Manual start",
|
||||||
|
"props":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"p": "payload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "20",
|
||||||
|
"topic": "",
|
||||||
|
"payload": "",
|
||||||
|
"payloadType": "date",
|
||||||
|
"x": 330,
|
||||||
|
"y": 3160,
|
||||||
|
"wires":
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"32ee1eade51fae50"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
## Gatus service health check
|
||||||
|
|
||||||
|
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
||||||
|
```
|
||||||
|
alerting:
|
||||||
|
custom:
|
||||||
|
url: "https://ntfy.sh"
|
||||||
|
method: "POST"
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
|
||||||
|
"title": "Gatus",
|
||||||
|
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
|
||||||
|
"priority": 3
|
||||||
|
}
|
||||||
|
default-alert:
|
||||||
|
enabled: true
|
||||||
|
description: "health check failed"
|
||||||
|
send-on-resolved: true
|
||||||
|
failure-threshold: 3
|
||||||
|
success-threshold: 3
|
||||||
|
placeholders:
|
||||||
|
ALERT_TRIGGERED_OR_RESOLVED:
|
||||||
|
TRIGGERED: "warning"
|
||||||
|
RESOLVED: "white_check_mark"
|
||||||
|
```
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ If you do not care for Firebase, I suggest you install the [F-Droid version](htt
|
|||||||
of the app and [self-host your own ntfy server](install.md).
|
of the app and [self-host your own ntfy server](install.md).
|
||||||
|
|
||||||
## How much battery does the Android app use?
|
## How much battery does the Android app use?
|
||||||
If you use the ntfy.sh server and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||||
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 4% of
|
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of
|
||||||
battery in 17h of use (on my phone). I use it, and it makes no difference to me.
|
battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||||
|
decent now.
|
||||||
|
|
||||||
## What is instant delivery?
|
## What is instant delivery?
|
||||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||||
|
|||||||
@@ -26,23 +26,29 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_x86_64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
tar zxvf ntfy_1.20.0_linux_x86_64.tar.gz
|
||||||
sudo ./ntfy serve
|
sudo cp -a ntfy_1.20.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
tar zxvf ntfy_1.20.0_linux_armv7.tar.gz
|
||||||
sudo ./ntfy serve
|
sudo cp -a ntfy_1.20.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
tar zxvf ntfy_1.20.0_linux_arm64.tar.gz
|
||||||
sudo ./ntfy serve
|
sudo cp -a ntfy_1.20.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debian/Ubuntu repository
|
## Debian/Ubuntu repository
|
||||||
@@ -88,7 +94,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -96,7 +102,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -104,7 +110,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -114,21 +120,21 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -146,7 +152,6 @@ cd ntfysh-bin
|
|||||||
makepkg -si
|
makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
||||||
straight forward to use.
|
straight forward to use.
|
||||||
@@ -181,6 +186,24 @@ docker run \
|
|||||||
serve
|
serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Using docker-compose:
|
||||||
|
```yaml
|
||||||
|
version: "2.1"
|
||||||
|
|
||||||
|
services:
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
container_name: ntfy
|
||||||
|
command:
|
||||||
|
- serve
|
||||||
|
volumes:
|
||||||
|
- /var/cache/ntfy:/var/cache/ntfy
|
||||||
|
- /etc/ntfy:/etc/ntfy
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||||
```
|
```
|
||||||
FROM binwiederhier/ntfy
|
FROM binwiederhier/ntfy
|
||||||
@@ -188,13 +211,3 @@ COPY server.yml /etc/ntfy/server.yml
|
|||||||
ENTRYPOINT ["ntfy", "serve"]
|
ENTRYPOINT ["ntfy", "serve"]
|
||||||
```
|
```
|
||||||
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||||
|
|
||||||
## Go
|
|
||||||
To install via Go, simply run:
|
|
||||||
```bash
|
|
||||||
go install heckel.io/ntfy@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! info
|
|
||||||
Please [let me know](https://github.com/binwiederhier/ntfy/issues) if there are any issues with this installation
|
|
||||||
method. The SQLite bindings require CGO and it works for me, but I have the feeling it may not work for everyone.
|
|
||||||
|
|||||||
284
docs/publish.md
284
docs/publish.md
@@ -36,6 +36,11 @@ Here's an example showing how to publish a simple message using a POST request:
|
|||||||
strings.NewReader("Backup successful 😀"))
|
strings.NewReader("Backup successful 😀"))
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/mytopic",
|
requests.post("https://ntfy.sh/mytopic",
|
||||||
@@ -117,6 +122,16 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/phil_alerts"
|
||||||
|
$headers = @{ Title="Unauthorized access detected"
|
||||||
|
Priority="urgent"
|
||||||
|
Tags="warning,skull" }
|
||||||
|
$body = "Remote access to phils-laptop detected. Act right away."
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/phil_alerts",
|
requests.post("https://ntfy.sh/phil_alerts",
|
||||||
@@ -191,6 +206,14 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/controversial"
|
||||||
|
$headers = @{ Title="Dogs are better than cats" }
|
||||||
|
$body = "Oh my ..."
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/controversial",
|
requests.post("https://ntfy.sh/controversial",
|
||||||
@@ -222,13 +245,13 @@ notification sounds and vibration patterns on your phone to map to these priorit
|
|||||||
|
|
||||||
The following priorities exist:
|
The following priorities exist:
|
||||||
|
|
||||||
| Priority | Icon | ID | Name | Description |
|
| Priority | Icon | ID | Name | Description |
|
||||||
|---|---|---|---|---|
|
|----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------|
|
||||||
| Max priority |  | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. |
|
| Max priority |  | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. |
|
||||||
| High priority |  | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. |
|
| High priority |  | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. |
|
||||||
| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. |
|
| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. |
|
||||||
| Low priority |  |`2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |
|
| Low priority |  | `2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |
|
||||||
| Min priority |  | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". |
|
| Min priority |  | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". |
|
||||||
|
|
||||||
You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`).
|
You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`).
|
||||||
|
|
||||||
@@ -271,6 +294,14 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/phil_alerts"
|
||||||
|
$headers = @{ Priority="5" }
|
||||||
|
$body = "An urgent message"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/phil_alerts",
|
requests.post("https://ntfy.sh/phil_alerts",
|
||||||
@@ -382,6 +413,14 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/backups"
|
||||||
|
$headers = @{ Tags="warning,mailsrv13,daily-backup" }
|
||||||
|
$body = "Backup of mailsrv13 failed"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/backups",
|
requests.post("https://ntfy.sh/backups",
|
||||||
@@ -464,6 +503,14 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/hello"
|
||||||
|
$headers = @{ At="tomorrow, 10am" }
|
||||||
|
$body = "Good morning"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/hello",
|
requests.post("https://ntfy.sh/hello",
|
||||||
@@ -499,7 +546,7 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
|
|||||||
</td>
|
</td>
|
||||||
</tr></table>
|
</tr></table>
|
||||||
|
|
||||||
## Webhooks (Send via GET)
|
## Webhooks (publish via GET)
|
||||||
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
|
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
|
||||||
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
|
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
|
||||||
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
|
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
|
||||||
@@ -538,6 +585,11 @@ For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhoo
|
|||||||
http.Get("https://ntfy.sh/mywebhook/trigger")
|
http.Get("https://ntfy.sh/mywebhook/trigger")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/trigger"
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.get("https://ntfy.sh/mywebhook/trigger")
|
requests.get("https://ntfy.sh/mywebhook/trigger")
|
||||||
@@ -582,6 +634,11 @@ Here's an example with a custom message, tags and a priority:
|
|||||||
http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
|
http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
|
requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
|
||||||
@@ -592,6 +649,159 @@ Here's an example with a custom message, tags and a priority:
|
|||||||
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
|
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Publish as JSON
|
||||||
|
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
|
||||||
|
adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
|
||||||
|
as JSON in the request body.
|
||||||
|
|
||||||
|
To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below
|
||||||
|
the example.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're
|
||||||
|
POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
|
||||||
|
|
||||||
|
Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter
|
||||||
|
is the only required one:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl ntfy.sh \
|
||||||
|
-d '{
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Disk space is low at 5.1 GB",
|
||||||
|
"title": "Low disk space alert",
|
||||||
|
"tags": ["warning","cd"],
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "https://filesrv.lan/space.jpg",
|
||||||
|
"filename": "diskspace.jpg",
|
||||||
|
"click": "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST / HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
|
||||||
|
{
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Disk space is low at 5.1 GB",
|
||||||
|
"title": "Low disk space alert",
|
||||||
|
"tags": ["warning","cd"],
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "https://filesrv.lan/space.jpg",
|
||||||
|
"filename": "diskspace.jpg",
|
||||||
|
"click": "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Disk space is low at 5.1 GB",
|
||||||
|
"title": "Low disk space alert",
|
||||||
|
"tags": ["warning","cd"],
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "https://filesrv.lan/space.jpg",
|
||||||
|
"filename": "diskspace.jpg",
|
||||||
|
"click": "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
// You should probably use json.Marshal() instead and make a proper struct,
|
||||||
|
// or even just use req.Header.Set() like in the other examples, but for the
|
||||||
|
// sake of the example, this is easier.
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Disk space is low at 5.1 GB",
|
||||||
|
"title": "Low disk space alert",
|
||||||
|
"tags": ["warning","cd"],
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "https://filesrv.lan/space.jpg",
|
||||||
|
"filename": "diskspace.jpg",
|
||||||
|
"click": "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
}`
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh"
|
||||||
|
$body = @{
|
||||||
|
"topic"="powershell"
|
||||||
|
"title"="Low disk space alert"
|
||||||
|
"message"="Disk space is low at 5.1 GB"
|
||||||
|
"priority"=4
|
||||||
|
"attach"="https://filesrv.lan/space.jpg"
|
||||||
|
"filename"="diskspace.jpg"
|
||||||
|
"tags"=@("warning","cd")
|
||||||
|
"click"= "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/",
|
||||||
|
data=json.dumps({
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Disk space is low at 5.1 GB",
|
||||||
|
"title": "Low disk space alert",
|
||||||
|
"tags": ["warning","cd"],
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "https://filesrv.lan/space.jpg",
|
||||||
|
"filename": "diskspace.jpg",
|
||||||
|
"click": "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json",
|
||||||
|
'content' => json_encode([
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Disk space is low at 5.1 GB",
|
||||||
|
"title": "Low disk space alert",
|
||||||
|
"tags": ["warning","cd"],
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "https://filesrv.lan/space.jpg",
|
||||||
|
"filename": "diskspace.jpg",
|
||||||
|
"click": "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
|
])
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md)
|
||||||
|
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
|
||||||
|
all the supported fields:
|
||||||
|
|
||||||
|
| Field | Required | Type | Example | Description |
|
||||||
|
|------------|----------|----------------------------------|--------------------------------|-----------------------------------------------------------------------|
|
||||||
|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
|
||||||
|
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
|
||||||
|
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
|
||||||
|
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
|
||||||
|
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
||||||
|
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
||||||
|
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||||
|
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||||
|
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
||||||
|
|
||||||
## Click action
|
## Click action
|
||||||
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
||||||
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
||||||
@@ -639,6 +849,14 @@ Here's an example that will open Reddit when the notification is clicked:
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/reddit_alerts"
|
||||||
|
$headers = @{ Click="https://www.reddit.com/message/messages" }
|
||||||
|
$body = "New messages on Reddit"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/reddit_alerts",
|
requests.post("https://ntfy.sh/reddit_alerts",
|
||||||
@@ -756,7 +974,13 @@ This could be a Dropbox link, a file from social media, or any other publicly av
|
|||||||
externally hosted, the expiration or size limits from above do not apply here.
|
externally hosted, the expiration or size limits from above do not apply here.
|
||||||
|
|
||||||
To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
|
To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
|
||||||
to specify the attachment URL. It can be any type of file. Here's an example showing how to attach an APK file:
|
to specify the attachment URL. It can be any type of file.
|
||||||
|
|
||||||
|
ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a
|
||||||
|
filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its
|
||||||
|
aliases `Filename`, `File` or `f`).
|
||||||
|
|
||||||
|
Here's an example showing how to attach an APK file:
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
@@ -795,6 +1019,13 @@ to specify the attachment URL. It can be any type of file. Here's an example sho
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/mydownloads"
|
||||||
|
$headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.put("https://ntfy.sh/mydownloads",
|
requests.put("https://ntfy.sh/mydownloads",
|
||||||
@@ -884,6 +1115,17 @@ that, your IP address appears in the e-mail body. This is to prevent abuse.
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/alerts"
|
||||||
|
$headers = @{ Title"="Low disk space alert"
|
||||||
|
Priority="high"
|
||||||
|
Tags="warning,skull,backup-host,ssh-login")
|
||||||
|
Email="phil@example.com" }
|
||||||
|
$body = "Unknown login from 5.31.23.83 to backups.example.com"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/alerts",
|
requests.post("https://ntfy.sh/alerts",
|
||||||
@@ -994,6 +1236,14 @@ Here's a simple example:
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.example.com/mysecrets"
|
||||||
|
$headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" }
|
||||||
|
$body = "Look ma, with auth"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.example.com/mysecrets",
|
requests.post("https://ntfy.example.com/mysecrets",
|
||||||
@@ -1069,6 +1319,14 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/mytopic"
|
||||||
|
$headers = @{ Cache="no" }
|
||||||
|
$body = "This message won't be stored server-side"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/mytopic",
|
requests.post("https://ntfy.sh/mytopic",
|
||||||
@@ -1141,6 +1399,14 @@ to `no`. This will instruct the server not to forward messages to Firebase.
|
|||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/mytopic"
|
||||||
|
$headers = @{ Firebase="no" }
|
||||||
|
$body = "This message won't be forwarded to FCM"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/mytopic",
|
requests.post("https://ntfy.sh/mytopic",
|
||||||
|
|||||||
126
docs/releases.md
126
docs/releases.md
@@ -2,6 +2,132 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
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).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
## ntfy Android app v1.11.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**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 (*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 [@RasHas](https://github.com/RasHas))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@RasHas](https://github.com/RasHas))
|
||||||
|
|
||||||
|
**Integrations:**
|
||||||
|
|
||||||
|
* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524),
|
||||||
|
thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work)
|
||||||
|
|
||||||
|
## ntfy server v1.19.0
|
||||||
|
Released Mar 30, 2022
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
|
||||||
|
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
|
||||||
|
* Add "Access-Control-Allow-Origin: *" for attachments (no ticket, thanks to @FrameXX)
|
||||||
|
* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186))
|
||||||
|
* Added missing params `delay` and `email` to publish as JSON body (no ticket)
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Improved [e-mail publishing](config.md#e-mail-publishing) documentation
|
||||||
|
|
||||||
|
## ntfy server v1.18.1
|
||||||
|
Released Mar 21, 2022
|
||||||
|
_This release ships no features or bug fixes. It's merely a documentation update._
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Overhaul of [developer documentation](https://ntfy.sh/docs/develop/)
|
||||||
|
* PowerShell examples for [publish documentation](https://ntfy.sh/docs/publish/) ([#138](https://github.com/binwiederhier/ntfy/issues/138), thanks to [@Joeharrison94](https://github.com/Joeharrison94))
|
||||||
|
* Additional examples for [NodeRED, Gatus, Sonarr, Radarr, ...](https://ntfy.sh/docs/examples/) (thanks to [@nickexyz](https://github.com/nickexyz))
|
||||||
|
* Fixes in developer instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
|
||||||
|
|
||||||
|
## ntfy Android app v1.10.0
|
||||||
|
Released Mar 21, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))
|
||||||
|
* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* Open "Click" link when tapping notification ([#110](https://github.com/binwiederhier/ntfy/issues/110), thanks [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* JSON stream deprecation banner ([#164](https://github.com/binwiederhier/ntfy/issues/164))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)
|
||||||
|
|
||||||
|
## ntfy server v1.18.0
|
||||||
|
Released Mar 16, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133),
|
||||||
|
thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and
|
||||||
|
[@Fallenbagel](https://github.com/Fallenbagel) for testing)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting)
|
||||||
|
* Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert))
|
||||||
|
* Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares))
|
||||||
|
|
||||||
|
**Deprecations:**
|
||||||
|
|
||||||
|
* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md)
|
||||||
|
|
||||||
|
## ntfy server v1.17.1
|
||||||
|
Released Mar 12, 2022
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Replace `crypto.subtle` with `hashCode` to errors with Brave/FF-Windows (#157, thanks for reporting @arminus)
|
||||||
|
|
||||||
## ntfy server v1.17.0
|
## ntfy server v1.17.0
|
||||||
Released Mar 11, 2022
|
Released Mar 11, 2022
|
||||||
|
|
||||||
|
|||||||
4
docs/static/css/extra.css
vendored
4
docs/static/css/extra.css
vendored
@@ -8,6 +8,10 @@
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-sidebar {
|
||||||
|
width: 12.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.md-typeset h4 {
|
.md-typeset h4 {
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
|
|||||||
BIN
docs/static/img/nodered-message.png
vendored
Normal file
BIN
docs/static/img/nodered-message.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
BIN
docs/static/img/nodered-picture.png
vendored
Normal file
BIN
docs/static/img/nodered-picture.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -130,19 +130,21 @@ notification popups:
|
|||||||
|
|
||||||
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
||||||
|
|
||||||
| Extra name | Type | Example | Description |
|
| Extra name | Type | Example | Description |
|
||||||
|---|---|---|---|
|
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
|
||||||
| `id` | *string* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||||
| `base_url` | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||||
| `topic` ❤️ | *string* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||||
| `muted` | *bool* | `true` | Indicates whether the subscription was muted in the app |
|
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||||
| `muted_str` | *string (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||||
| `time` | *int* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `title` | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||||
| `message` ❤️ | *string* | `Some message` | Message body; **this is likely what you're interested in** |
|
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||||
| `tags` | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||||
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||||
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
|
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||||
|
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
#### Send messages using intents
|
#### Send messages using intents
|
||||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
@@ -164,14 +166,14 @@ Here's what that looks like:
|
|||||||
|
|
||||||
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
||||||
|
|
||||||
| Extra name | Required | Type | Example | Description |
|
| Extra name | Required | Type | Example | Description |
|
||||||
|---|---|---|---|---|
|
|--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------|
|
||||||
| `base_url` | - | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
| `base_url` | - | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
||||||
| `topic` ❤️ | ✔ | *string* | `mytopic` | Topic name; **you must set this** |
|
| `topic` ❤️ | ✔ | *String* | `mytopic` | Topic name; **you must set this** |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
| `title` | - | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||||
| `message` ❤️ | ✔ | *string* | `Some message` | Message body; **you must set this** |
|
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||||
| `tags` | - | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
| `priority` | - | *string or int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
## iPhone/iOS
|
## iPhone/iOS
|
||||||
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
||||||
|
|||||||
36
go.mod
36
go.mod
@@ -4,48 +4,48 @@ go 1.17
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||||
cloud.google.com/go/storage v1.19.0 // indirect
|
cloud.google.com/go/storage v1.21.0 // indirect
|
||||||
firebase.google.com/go v3.13.0+incompatible
|
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/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0
|
github.com/gabriel-vasile/mimetype v1.4.0
|
||||||
github.com/gorilla/websocket v1.4.2
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.11
|
github.com/mattn/go-sqlite3 v1.14.12
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/urfave/cli/v2 v2.3.0
|
github.com/urfave/cli/v2 v2.4.0
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
|
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
|
||||||
google.golang.org/api v0.67.0
|
google.golang.org/api v0.74.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.100.2 // indirect
|
cloud.google.com/go v0.100.2 // indirect
|
||||||
cloud.google.com/go/compute v1.2.0 // indirect
|
cloud.google.com/go/compute v1.5.0 // indirect
|
||||||
cloud.google.com/go/iam v0.1.1 // indirect
|
cloud.google.com/go/iam v0.3.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.0.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // 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/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.7 // indirect
|
github.com/google/go-cmp v0.5.7 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
|
github.com/googleapis/gax-go/v2 v2.2.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
go.opencensus.io v0.23.0 // indirect
|
go.opencensus.io v0.23.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
|
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect
|
||||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
|
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
golang.org/x/text v0.3.7 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f // indirect
|
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect
|
||||||
google.golang.org/grpc v1.44.0 // indirect
|
google.golang.org/grpc v1.45.0 // indirect
|
||||||
google.golang.org/protobuf v1.27.1 // indirect
|
google.golang.org/protobuf v1.28.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
97
go.sum
97
go.sum
@@ -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.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
|
cloud.google.com/go/compute 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.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 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM=
|
||||||
|
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
||||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||||
cloud.google.com/go/iam v0.1.1 h1:4CapQyNFjiksks1/x7jsvsygFPhihslYk5GptIrlX68=
|
|
||||||
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
|
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.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
@@ -53,16 +56,19 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
|||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
cloud.google.com/go/storage v1.19.0 h1:XOQSnPJD8hRtZJ3VdCyK0mBZsGGImrzPAMbSWcHSe6Q=
|
cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14=
|
||||||
cloud.google.com/go/storage v1.19.0/go.mod h1:6rgiTRjOqI/Zd9YKimub5TIB4d+p3LH33V3ZE1DMuUM=
|
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
|
||||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||||
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
|
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.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/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
@@ -81,7 +87,6 @@ github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWH
|
|||||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/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 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -182,10 +187,11 @@ 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.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
||||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
|
||||||
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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
@@ -199,8 +205,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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.11 h1:gt+cp9c0XGqe9S/wAHTL3n/7MqY+siPWgWJgqdsFrzQ=
|
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
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 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@@ -211,10 +217,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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
@@ -223,8 +227,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -243,8 +247,11 @@ 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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s=
|
||||||
|
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
|
||||||
|
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -316,8 +323,13 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
|
|||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
|
||||||
|
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-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
|
||||||
|
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@@ -334,8 +346,10 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
|
|||||||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -399,8 +413,15 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-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-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-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-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs=
|
||||||
|
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/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-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
|
||||||
|
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -417,8 +438,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
|
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
@@ -508,10 +529,15 @@ google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUb
|
|||||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
||||||
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
||||||
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
|
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.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.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
|
||||||
|
google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
|
||||||
|
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
|
||||||
|
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
|
||||||
|
google.golang.org/api v0.73.0 h1:O9bThUh35K1rvUrQwTUQ1eqLC/IYyzUpWavYIO2EXvo=
|
||||||
|
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
|
||||||
|
google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE=
|
||||||
|
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -583,14 +609,22 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6
|
|||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-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-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-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-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-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-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-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-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220322021311-435b647f9ef2 h1:3n0D2NdPGm0g0wrVJzXJWW5CBOoqgGBkDX9cRMJHZAY=
|
||||||
|
google.golang.org/genproto v0.0.0-20220322021311-435b647f9ef2/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
||||||
|
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
||||||
|
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf h1:JTjwKJX9erVpsw17w+OIPP7iAgEkN/r8urhWSunEDTs=
|
||||||
|
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
@@ -617,8 +651,9 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
|
|||||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||||
google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
|
|
||||||
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
||||||
|
google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
|
||||||
|
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
@@ -632,14 +667,16 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
|
|||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
|
||||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||||
|
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -19,10 +19,11 @@ func main() {
|
|||||||
Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.
|
Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.
|
||||||
|
|
||||||
To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.
|
To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.
|
||||||
If you want to chat, simply join the Discord server: https://discord.gg/cT7ECsZj9w.
|
If you want to chat, simply join the Discord server (https://discord.gg/cT7ECsZj9w), or
|
||||||
|
the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
||||||
|
|
||||||
ntfy %s (%s), runtime %s, built at %s
|
ntfy %s (%s), runtime %s, built at %s
|
||||||
Copyright (C) 2021 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
Copyright (C) 2022 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||||
`, version, commit[:7], runtime.Version(), date)
|
`, version, commit[:7], runtime.Version(), date)
|
||||||
|
|
||||||
app := cmd.New()
|
app := cmd.New()
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
# The documentation uses 'mkdocs', which is written in Python
|
# The documentation uses 'mkdocs', which is written in Python
|
||||||
|
|
||||||
# See https://github.com/squidfunk/mkdocs-material/issues/2030
|
|
||||||
jinja2>=2.11.1
|
|
||||||
|
|
||||||
# mkdocs
|
|
||||||
mkdocs
|
|
||||||
mkdocs-material
|
mkdocs-material
|
||||||
mkdocs-minify-plugin
|
mkdocs-minify-plugin
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if [ -z "$1" ]; then
|
|||||||
echo "Syntax: $0 FILE.(js|json|md)"
|
echo "Syntax: $0 FILE.(js|json|md)"
|
||||||
echo "Example:"
|
echo "Example:"
|
||||||
echo " $0 emoji-converted.json"
|
echo " $0 emoji-converted.json"
|
||||||
echo " $0 $ROOTDIR/server/static/js/emoji.js"
|
echo " $0 $ROOTDIR/web/src/app/emojis.js"
|
||||||
echo " $0 $ROOTDIR/docs/emojis.md"
|
echo " $0 $ROOTDIR/docs/emojis.md"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -19,7 +19,7 @@ if [[ "$1" == *.js ]]; then
|
|||||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||||
export const rawEmojis = " > "$1"
|
export const rawEmojis = " > "$1"
|
||||||
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
|
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji, aliases: .aliases, tags: .tags, category: .category, description: .description, unicode_version: .unicode_version})' >> "$1"
|
||||||
elif [[ "$1" == *.md ]]; then
|
elif [[ "$1" == *.md ]]; then
|
||||||
echo "# Emoji reference
|
echo "# Emoji reference
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,15 @@ var (
|
|||||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
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", "https://ntfy.sh/docs/publish/#attachments"}
|
||||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
||||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
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", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", ""}
|
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "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"}
|
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"}
|
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"}
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ func (c *messageCache) Prune(olderThan time.Time) error {
|
|||||||
return err
|
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())
|
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -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, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||||
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
||||||
|
|
||||||
size, err := c.AttachmentsSize("1.2.3.4")
|
size, err := c.AttachmentBytesUsed("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(30000), size)
|
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.Nil(t, err)
|
||||||
require.Equal(t, int64(0), size)
|
require.Equal(t, int64(0), size)
|
||||||
|
|
||||||
|
|||||||
127
server/server.go
127
server/server.go
@@ -55,17 +55,18 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
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!
|
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
|
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$`)
|
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$`)
|
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$`)
|
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$`)
|
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$`)
|
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)$`)
|
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||||
|
|
||||||
webConfigPath = "/config.js"
|
webConfigPath = "/config.js"
|
||||||
|
userStatsPath = "/user/stats"
|
||||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
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)
|
return s.handleEmpty(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||||
return s.handleWebConfig(w, r)
|
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) {
|
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||||
return s.handleStatic(w, r)
|
return s.handleStatic(w, r)
|
||||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
} 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)
|
return s.limitRequests(s.handleFile)(w, r, v)
|
||||||
} else if r.Method == http.MethodOptions {
|
} else if r.Method == http.MethodOptions {
|
||||||
return s.handleOptions(w, r)
|
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) {
|
} 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)
|
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
} 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)
|
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
|
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 s.handleTopic(w, r)
|
||||||
}
|
}
|
||||||
return errHTTPNotFound
|
return errHTTPNotFound
|
||||||
@@ -349,6 +354,19 @@ var config = {
|
|||||||
return err
|
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 {
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||||
r.URL.Path = webSiteDir + r.URL.Path
|
r.URL.Path = webSiteDir + r.URL.Path
|
||||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
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
|
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
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)
|
f, err := os.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -392,7 +411,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body, err := util.Peak(r.Body, s.config.MessageLimit)
|
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -536,35 +555,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
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
// 5. curl -T file.txt ntfy.sh/mytopic
|
// 5. curl -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is > message limit, treat it as an attachment
|
// 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 {
|
if unifiedpush {
|
||||||
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
|
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
|
||||||
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 2
|
return s.handleBodyAsTextMessage(m, body) // Case 2
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
|
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.handleBodyAsTextMessage(m, body) // Case 4
|
||||||
}
|
}
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
|
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
|
||||||
if utf8.Valid(body.PeakedBytes) {
|
if utf8.Valid(body.PeekedBytes) {
|
||||||
m.Message = string(body.PeakedBytes) // Do not trim
|
m.Message = string(body.PeekedBytes) // Do not trim
|
||||||
} else {
|
} else {
|
||||||
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
|
m.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)
|
||||||
m.Encoding = encodingBase64
|
m.Encoding = encodingBase64
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error {
|
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||||
if !utf8.Valid(body.PeakedBytes) {
|
if !utf8.Valid(body.PeekedBytes) {
|
||||||
return errHTTPBadRequestMessageNotUTF8
|
return errHTTPBadRequestMessageNotUTF8
|
||||||
}
|
}
|
||||||
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
if len(body.PeekedBytes) > 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
|
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 == "" {
|
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
|
||||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
||||||
@@ -572,22 +591,21 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser
|
|||||||
return nil
|
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 == "" {
|
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||||
return errHTTPBadRequestAttachmentsDisallowed
|
return errHTTPBadRequestAttachmentsDisallowed
|
||||||
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
|
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
|
||||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||||
}
|
}
|
||||||
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip)
|
visitorStats, err := v.Stats()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
|
|
||||||
contentLengthStr := r.Header.Get("Content-Length")
|
contentLengthStr := r.Header.Get("Content-Length")
|
||||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||||
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) {
|
if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
|
||||||
return errHTTPBadRequestAttachmentTooLarge
|
return errHTTPEntityTooLargeAttachmentTooLarge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.Attachment == nil {
|
if m.Attachment == nil {
|
||||||
@@ -596,7 +614,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||||||
var ext string
|
var ext string
|
||||||
m.Attachment.Owner = v.ip // Important for attachment rate limiting
|
m.Attachment.Owner = v.ip // Important for attachment rate limiting
|
||||||
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
|
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)
|
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
|
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
|
||||||
@@ -604,9 +622,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
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 {
|
if err == util.ErrLimitReached {
|
||||||
return errHTTPBadRequestAttachmentTooLarge
|
return errHTTPEntityTooLargeAttachmentTooLarge
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1111,55 @@ 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 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 {
|
func (s *Server) authWrite(next handleFunc) handleFunc {
|
||||||
return s.withAuth(next, auth.PermissionWrite)
|
return s.withAuth(next, auth.PermissionWrite)
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1231,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
|
|||||||
}
|
}
|
||||||
v, exists := s.visitors[ip]
|
v, exists := s.visitors[ip]
|
||||||
if !exists {
|
if !exists {
|
||||||
s.visitors[ip] = newVisitor(s.config, ip)
|
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
|
||||||
return s.visitors[ip]
|
return s.visitors[ip]
|
||||||
}
|
}
|
||||||
v.Keepalive()
|
v.Keepalive()
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ func TestServer_PublishPriority(t *testing.T) {
|
|||||||
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
|
||||||
|
// This tests a bug that allowed publishing topics with a comma in the name (no ticket)
|
||||||
|
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil)
|
||||||
|
require.Equal(t, 404, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishNoCache(t *testing.T) {
|
func TestServer_PublishNoCache(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -706,6 +714,12 @@ func (t *testMailer) Send(from, to string, m *message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) Count() int {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.count
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
for i := 0; i < 60; i++ {
|
for i := 0; i < 60; i++ {
|
||||||
@@ -862,6 +876,48 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
|
|||||||
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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_Invalid(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
body := `{"topic":"mytopic",INVALID`
|
||||||
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachment(t *testing.T) {
|
func TestServer_PublishAttachment(t *testing.T) {
|
||||||
content := util.RandomString(5000) // > 4096
|
content := util.RandomString(5000) // > 4096
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
@@ -870,7 +926,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
|||||||
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
||||||
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||||
require.Equal(t, int64(5000), msg.Attachment.Size)
|
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
@@ -882,7 +938,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
|||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request()
|
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(5000), size)
|
require.Equal(t, int64(5000), size)
|
||||||
}
|
}
|
||||||
@@ -911,7 +967,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
|||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.messageCache.AttachmentsSize("1.2.3.4")
|
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(21), size)
|
require.Equal(t, int64(21), size)
|
||||||
}
|
}
|
||||||
@@ -931,7 +987,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
|||||||
require.Equal(t, "", msg.Attachment.Owner)
|
require.Equal(t, "", msg.Attachment.Owner)
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
||||||
size, err := s.messageCache.AttachmentsSize("127.0.0.1")
|
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), size)
|
require.Equal(t, int64(0), size)
|
||||||
}
|
}
|
||||||
@@ -968,9 +1024,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
|
|||||||
"Content-Length": "20000000",
|
"Content-Length": "20000000",
|
||||||
})
|
})
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 400, err.HTTPCode)
|
require.Equal(t, 413, err.HTTPCode)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
||||||
@@ -980,9 +1036,9 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.
|
|||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 400, err.HTTPCode)
|
require.Equal(t, 413, err.HTTPCode)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
||||||
@@ -1012,9 +1068,9 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
|||||||
content := util.RandomString(5001) // 5000+5001 > , see below
|
content := util.RandomString(5001) // 5000+5001 > , see below
|
||||||
response = request(t, s, "PUT", "/mytopic", content, nil)
|
response = request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 400, err.HTTPCode)
|
require.Equal(t, 413, err.HTTPCode)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
||||||
@@ -1088,8 +1144,32 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
|||||||
// And a failed one
|
// And a failed one
|
||||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentUserStats(t *testing.T) {
|
||||||
|
content := util.RandomString(4999) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AttachmentFileSizeLimit = 5000
|
||||||
|
c.VisitorAttachmentTotalSizeLimit = 6000
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Upload one attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
|
||||||
|
// User stats
|
||||||
|
response = request(t, s, "GET", "/user/stats", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
var stats visitorStats
|
||||||
|
require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats))
|
||||||
|
require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit)
|
||||||
|
require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal)
|
||||||
|
require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed)
|
||||||
|
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ type attachment struct {
|
|||||||
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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
|
// messageEncoder is a function that knows how to encode a message
|
||||||
type messageEncoder func(msg *message) (string, error)
|
type messageEncoder func(msg *message) (string, error)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ var (
|
|||||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
|
messageCache *messageCache
|
||||||
ip string
|
ip string
|
||||||
requests *rate.Limiter
|
requests *rate.Limiter
|
||||||
emails *rate.Limiter
|
emails *rate.Limiter
|
||||||
@@ -31,9 +32,17 @@ type visitor struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *Config, ip string) *visitor {
|
type visitorStats struct {
|
||||||
|
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
|
||||||
|
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
|
||||||
|
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
|
||||||
|
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
|
messageCache: messageCache,
|
||||||
ip: ip,
|
ip: ip,
|
||||||
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
||||||
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
||||||
@@ -91,3 +100,20 @@ func (v *visitor) Stale() bool {
|
|||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
return time.Since(v.seen) > visitorExpungeAfter
|
return time.Since(v.seen) > visitorExpungeAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) Stats() (*visitorStats, error) {
|
||||||
|
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
|
||||||
|
if attachmentsBytesRemaining < 0 {
|
||||||
|
attachmentsBytesRemaining = 0
|
||||||
|
}
|
||||||
|
return &visitorStats{
|
||||||
|
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
|
||||||
|
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
|
||||||
|
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
|
||||||
|
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
61
util/peak.go
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
61
util/peek.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.
|
||||||
|
// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully
|
||||||
|
// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining
|
||||||
|
// underlying reader.
|
||||||
|
type PeekedReadCloser struct {
|
||||||
|
PeekedBytes []byte
|
||||||
|
LimitReached bool
|
||||||
|
peeked io.Reader
|
||||||
|
underlying io.ReadCloser
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
|
||||||
|
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
||||||
|
if underlying == nil {
|
||||||
|
underlying = io.NopCloser(strings.NewReader(""))
|
||||||
|
}
|
||||||
|
peeked := make([]byte, limit)
|
||||||
|
read, err := io.ReadFull(underlying, peeked)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PeekedReadCloser{
|
||||||
|
PeekedBytes: peeked[:read],
|
||||||
|
LimitReached: read == limit,
|
||||||
|
underlying: underlying,
|
||||||
|
peeked: bytes.NewReader(peeked[:read]),
|
||||||
|
closed: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads from the peeked bytes and then from the underlying stream
|
||||||
|
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
|
||||||
|
if r.closed {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n, err = r.peeked.Read(p)
|
||||||
|
if err == io.EOF {
|
||||||
|
return r.underlying.Read(p)
|
||||||
|
} else if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying stream
|
||||||
|
func (r *PeekedReadCloser) Close() error {
|
||||||
|
if r.closed {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
r.closed = true
|
||||||
|
return r.underlying.Close()
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
|
|
||||||
func TestPeak_LimitReached(t *testing.T) {
|
func TestPeak_LimitReached(t *testing.T) {
|
||||||
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||||
peaked, err := Peak(underlying, 5)
|
peaked, err := Peek(underlying, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
|
||||||
require.Equal(t, true, peaked.LimitReached)
|
require.Equal(t, true, peaked.LimitReached)
|
||||||
|
|
||||||
all, err := io.ReadAll(peaked)
|
all, err := io.ReadAll(peaked)
|
||||||
@@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte("1234567890"), all)
|
require.Equal(t, []byte("1234567890"), all)
|
||||||
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
|
||||||
require.Equal(t, true, peaked.LimitReached)
|
require.Equal(t, true, peaked.LimitReached)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeak_LimitNotReached(t *testing.T) {
|
func TestPeak_LimitNotReached(t *testing.T) {
|
||||||
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||||
peaked, err := Peak(underlying, 15)
|
peaked, err := Peek(underlying, 15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte("1234567890"), all)
|
require.Equal(t, []byte("1234567890"), all)
|
||||||
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes)
|
require.Equal(t, []byte("1234567890"), peaked.PeekedBytes)
|
||||||
require.Equal(t, false, peaked.LimitReached)
|
require.Equal(t, false, peaked.LimitReached)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeak_Nil(t *testing.T) {
|
func TestPeak_Nil(t *testing.T) {
|
||||||
peaked, err := Peak(nil, 15)
|
peaked, err := Peek(nil, 15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte(""), all)
|
require.Equal(t, []byte(""), all)
|
||||||
require.Equal(t, []byte(""), peaked.PeakedBytes)
|
require.Equal(t, []byte(""), peaked.PeekedBytes)
|
||||||
require.Equal(t, false, peaked.LimitReached)
|
require.Equal(t, false, peaked.LimitReached)
|
||||||
}
|
}
|
||||||
4369
web/package-lock.json
generated
4369
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,7 @@
|
|||||||
"react-router-dom": "^6.2.2",
|
"react-router-dom": "^6.2.2",
|
||||||
"react-scripts": "^5.0.0",
|
"react-scripts": "^5.0.0",
|
||||||
"stacktrace-gps": "^3.0.4",
|
"stacktrace-gps": "^3.0.4",
|
||||||
"stacktrace-js": "^2.0.2",
|
"stacktrace-js": "^2.0.2"
|
||||||
"svgo": "^2.8.0"
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a, a:visited {
|
a, a:visited {
|
||||||
color: #3a9784;
|
color: #338574;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
@@ -114,7 +114,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.anchor .anchorLink:hover {
|
.anchor .anchorLink:hover {
|
||||||
color: #3a9784;
|
color: #338574;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ figcaption {
|
|||||||
/* Header */
|
/* Header */
|
||||||
|
|
||||||
#header {
|
#header {
|
||||||
background: #3a9784;
|
background: #338574;
|
||||||
height: 130px;
|
height: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
basicAuth,
|
||||||
|
encodeBase64,
|
||||||
fetchLinesIterator,
|
fetchLinesIterator,
|
||||||
maybeWithBasicAuth,
|
maybeWithBasicAuth,
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
topicUrl,
|
topicUrl,
|
||||||
topicUrlAuth,
|
topicUrlAuth,
|
||||||
topicUrlJsonPoll,
|
topicUrlJsonPoll,
|
||||||
topicUrlJsonPollWithSince
|
topicUrlJsonPollWithSince, userStatsUrl
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
|
|
||||||
@@ -26,25 +28,78 @@ class Api {
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async publish(baseUrl, topic, message, title, priority, tags) {
|
async publish(baseUrl, topic, message, options) {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
const url = topicUrl(baseUrl, topic);
|
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
||||||
console.log(`[Api] Publishing message to ${url}`);
|
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (title) {
|
const body = {
|
||||||
headers["X-Title"] = title;
|
topic: topic,
|
||||||
}
|
message: message,
|
||||||
if (priority !== 3) {
|
...options
|
||||||
headers["X-Priority"] = `${priority}`;
|
};
|
||||||
}
|
const response = await fetch(baseUrl, {
|
||||||
if (tags.length > 0) {
|
|
||||||
headers["X-Tags"] = tags.join(",");
|
|
||||||
}
|
|
||||||
await fetch(url, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: message,
|
body: JSON.stringify(body),
|
||||||
headers: maybeWithBasicAuth(headers, user)
|
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) {
|
async auth(baseUrl, topic, user) {
|
||||||
@@ -62,6 +117,18 @@ class Api {
|
|||||||
}
|
}
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
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();
|
const api = new Api();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Connection from "./Connection";
|
import Connection from "./Connection";
|
||||||
import {sha256} from "./utils";
|
import {hashCode} from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
||||||
@@ -108,10 +108,9 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const makeConnectionId = async (subscription, user) => {
|
const makeConnectionId = async (subscription, user) => {
|
||||||
const hash = (user)
|
return (user)
|
||||||
? await sha256(`${subscription.id}|${user.username}|${user.password}`)
|
? hashCode(`${subscription.id}|${user.username}|${user.password}`)
|
||||||
: await sha256(`${subscription.id}`);
|
: hashCode(`${subscription.id}`);
|
||||||
return hash.substring(0, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager();
|
const connectionManager = new ConnectionManager();
|
||||||
|
|||||||
@@ -56,6 +56,4 @@ class Poller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const poller = new Poller();
|
const poller = new Poller();
|
||||||
poller.startWorker();
|
|
||||||
|
|
||||||
export default poller;
|
export default poller;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
|
|
||||||
const delayMillis = 15000; // 15 seconds
|
const delayMillis = 25000; // 25 seconds
|
||||||
const intervalMillis = 1800000; // 30 minutes
|
const intervalMillis = 1800000; // 30 minutes
|
||||||
|
|
||||||
class Pruner {
|
class Pruner {
|
||||||
@@ -35,6 +35,4 @@ class Pruner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pruner = new Pruner();
|
const pruner = new Pruner();
|
||||||
pruner.startWorker();
|
|
||||||
|
|
||||||
export default pruner;
|
export default pruner;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
|
|||||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
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 shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||||
export const expandSecureUrl = (url) => `https://${url}`;
|
export const expandSecureUrl = (url) => `https://${url}`;
|
||||||
@@ -115,10 +116,22 @@ export const shuffle = (arr) => {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://jameshfisher.com/2017/10/30/web-cryptography-api-hello-world/
|
export const splitNoEmpty = (s, delimiter) => {
|
||||||
export const sha256 = async (s) => {
|
return s
|
||||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder("utf-8").encode(s));
|
.split(delimiter)
|
||||||
return Array.prototype.map.call(new Uint8Array(buf), x=>(('00'+x.toString(16)).slice(-2))).join('');
|
.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;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const char = s.charCodeAt(i);
|
||||||
|
hash = ((hash<<5)-hash)+char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatShortDateTime = (timestamp) => {
|
export const formatShortDateTime = (timestamp) => {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const SettingsIcons = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendTestMessage = () => {
|
const handleSendTestMessage = async () => {
|
||||||
const baseUrl = props.subscription.baseUrl;
|
const baseUrl = props.subscription.baseUrl;
|
||||||
const topic = props.subscription.topic;
|
const topic = props.subscription.topic;
|
||||||
const tags = shuffle([
|
const tags = shuffle([
|
||||||
@@ -121,18 +121,29 @@ const SettingsIcons = (props) => {
|
|||||||
"Titles are optional, did you know that?",
|
"Titles are optional, did you know that?",
|
||||||
"ntfy is open source, and will always be free. Cool, right?",
|
"ntfy is open source, and will always be free. Cool, right?",
|
||||||
"I don't really like apples",
|
"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];
|
])[0];
|
||||||
|
const nowSeconds = Math.round(Date.now()/1000);
|
||||||
const message = shuffle([
|
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.`,
|
`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.`,
|
`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 ...`,
|
`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?`
|
`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];
|
])[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);
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from
|
|||||||
import {expandUrl} from "../app/utils";
|
import {expandUrl} from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
|
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
|
||||||
|
import SendDialog from "./SendDialog";
|
||||||
|
import Messaging from "./Messaging";
|
||||||
|
|
||||||
// TODO add drag and drop
|
|
||||||
// TODO races when two tabs are open
|
// TODO races when two tabs are open
|
||||||
// TODO investigate service workers
|
// TODO investigate service workers
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ const Layout = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
||||||
|
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||||
@@ -67,7 +69,7 @@ const Layout = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useConnectionListeners(subscriptions, users);
|
useConnectionListeners(subscriptions, users);
|
||||||
useLocalStorageMigration();
|
useBackgroundProcesses();
|
||||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,11 +86,17 @@ const Layout = () => {
|
|||||||
mobileDrawerOpen={mobileDrawerOpen}
|
mobileDrawerOpen={mobileDrawerOpen}
|
||||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||||
onNotificationGranted={setNotificationsGranted}
|
onNotificationGranted={setNotificationsGranted}
|
||||||
|
onPublishMessageClick={() => setSendDialogOpenMode(SendDialog.OPEN_MODE_DEFAULT)}
|
||||||
/>
|
/>
|
||||||
<Main>
|
<Main>
|
||||||
<Toolbar/>
|
<Toolbar/>
|
||||||
<Outlet context={{ subscriptions, selected }}/>
|
<Outlet context={{ subscriptions, selected }}/>
|
||||||
</Main>
|
</Main>
|
||||||
|
<Messaging
|
||||||
|
selected={selected}
|
||||||
|
dialogOpenMode={sendDialogOpenMode}
|
||||||
|
onDialogOpenModeChange={setSendDialogOpenMode}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
web/src/components/AttachmentIcon.js
Normal file
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
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;
|
||||||
169
web/src/components/EmojiPicker.js
Normal file
169
web/src/components/EmojiPicker.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// 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 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="Search emoji"
|
||||||
|
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;
|
||||||
111
web/src/components/Messaging.js
Normal file
111
web/src/components/Messaging.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import Navigation from "./Navigation";
|
||||||
|
import {topicUrl} from "../app/utils";
|
||||||
|
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 SendDialog from "./SendDialog";
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import {Portal, Snackbar} from "@mui/material";
|
||||||
|
|
||||||
|
const Messaging = (props) => {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
|
||||||
|
const dialogOpenMode = props.dialogOpenMode;
|
||||||
|
const subscription = props.selected;
|
||||||
|
|
||||||
|
const handleOpenDialogClick = () => {
|
||||||
|
props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendDialogClose = () => {
|
||||||
|
props.onDialogOpenModeChange("");
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{subscription && <MessageBar
|
||||||
|
subscription={subscription}
|
||||||
|
message={message}
|
||||||
|
onMessageChange={setMessage}
|
||||||
|
onOpenDialogClick={handleOpenDialogClick}
|
||||||
|
/>}
|
||||||
|
<SendDialog
|
||||||
|
key={`sendDialog${dialogKey}`} // Resets dialog when canceled/closed
|
||||||
|
openMode={dialogOpenMode}
|
||||||
|
baseUrl={subscription?.baseUrl ?? window.location.origin}
|
||||||
|
topic={subscription?.topic ?? ""}
|
||||||
|
message={message}
|
||||||
|
onClose={handleSendDialogClose}
|
||||||
|
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : SendDialog.OPEN_MODE_DRAG)} // Only update if not already open
|
||||||
|
onResetOpenMode={() => props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageBar = (props) => {
|
||||||
|
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="Type a message here"
|
||||||
|
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="Error publishing message"
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Messaging;
|
||||||
@@ -19,7 +19,7 @@ import routes from "./routes";
|
|||||||
import {ConnectionState} from "../app/Connection";
|
import {ConnectionState} from "../app/Connection";
|
||||||
import {useLocation, useNavigate} from "react-router-dom";
|
import {useLocation, useNavigate} from "react-router-dom";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
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 Box from "@mui/material/Box";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import config from "../app/config";
|
import config from "../app/config";
|
||||||
@@ -118,9 +118,13 @@ const NavList = (props) => {
|
|||||||
<ListItemIcon><ArticleIcon/></ListItemIcon>
|
<ListItemIcon><ArticleIcon/></ListItemIcon>
|
||||||
<ListItemText primary="Documentation"/>
|
<ListItemText primary="Documentation"/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
<ListItemButton onClick={() => props.onPublishMessageClick()}>
|
||||||
|
<ListItemIcon><Send/></ListItemIcon>
|
||||||
|
<ListItemText primary="Publish message"/>
|
||||||
|
</ListItemButton>
|
||||||
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
|
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
|
||||||
<ListItemIcon><AddIcon/></ListItemIcon>
|
<ListItemIcon><AddIcon/></ListItemIcon>
|
||||||
<ListItemText primary="Add subscription"/>
|
<ListItemText primary="Subscribe to topic"/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</List>
|
</List>
|
||||||
<SubscribeDialog
|
<SubscribeDialog
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
formatMessage,
|
formatMessage,
|
||||||
formatShortDateTime,
|
formatShortDateTime,
|
||||||
formatTitle,
|
formatTitle,
|
||||||
openUrl, shortUrl,
|
openUrl,
|
||||||
|
shortUrl,
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
unmatchedTags
|
unmatchedTags
|
||||||
} from "../app/utils";
|
} from "../app/utils";
|
||||||
@@ -32,16 +33,12 @@ import Box from "@mui/material/Box";
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import InfiniteScroll from "react-infinite-scroll-component";
|
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 priority1 from "../img/priority-1.svg";
|
||||||
import priority2 from "../img/priority-2.svg";
|
import priority2 from "../img/priority-2.svg";
|
||||||
import priority4 from "../img/priority-4.svg";
|
import priority4 from "../img/priority-4.svg";
|
||||||
import priority5 from "../img/priority-5.svg";
|
import priority5 from "../img/priority-5.svg";
|
||||||
import logoOutline from "../img/ntfy-outline.svg";
|
import logoOutline from "../img/ntfy-outline.svg";
|
||||||
|
import AttachmentIcon from "./AttachmentIcon";
|
||||||
|
|
||||||
const Notifications = (props) => {
|
const Notifications = (props) => {
|
||||||
if (props.mode === "all") {
|
if (props.mode === "all") {
|
||||||
@@ -60,7 +57,7 @@ const AllSubscriptions = (props) => {
|
|||||||
} else if (notifications.length === 0) {
|
} else if (notifications.length === 0) {
|
||||||
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
|
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
|
||||||
}
|
}
|
||||||
return <NotificationList key="all" notifications={notifications}/>;
|
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleSubscription = (props) => {
|
const SingleSubscription = (props) => {
|
||||||
@@ -71,7 +68,7 @@ const SingleSubscription = (props) => {
|
|||||||
} else if (notifications.length === 0) {
|
} else if (notifications.length === 0) {
|
||||||
return <NoNotifications subscription={subscription}/>;
|
return <NoNotifications subscription={subscription}/>;
|
||||||
}
|
}
|
||||||
return <NotificationList id={subscription.id} notifications={notifications}/>;
|
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationList = (props) => {
|
const NotificationList = (props) => {
|
||||||
@@ -97,7 +94,13 @@ const NotificationList = (props) => {
|
|||||||
scrollThreshold={0.7}
|
scrollThreshold={0.7}
|
||||||
scrollableTarget="main"
|
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}>
|
<Stack spacing={3}>
|
||||||
{notifications.slice(0, count).map(notification =>
|
{notifications.slice(0, count).map(notification =>
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
@@ -119,13 +122,12 @@ const NotificationList = (props) => {
|
|||||||
|
|
||||||
const NotificationItem = (props) => {
|
const NotificationItem = (props) => {
|
||||||
const notification = props.notification;
|
const notification = props.notification;
|
||||||
const subscriptionId = notification.subscriptionId;
|
|
||||||
const attachment = notification.attachment;
|
const attachment = notification.attachment;
|
||||||
const date = formatShortDateTime(notification.time);
|
const date = formatShortDateTime(notification.time);
|
||||||
const otherTags = unmatchedTags(notification.tags);
|
const otherTags = unmatchedTags(notification.tags);
|
||||||
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
|
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
|
console.log(`[Notifications] Deleting notification ${notification.id}`);
|
||||||
await subscriptionManager.deleteNotification(notification.id)
|
await subscriptionManager.deleteNotification(notification.id)
|
||||||
}
|
}
|
||||||
const handleCopy = (s) => {
|
const handleCopy = (s) => {
|
||||||
@@ -239,7 +241,7 @@ const Attachment = (props) => {
|
|||||||
padding: 1,
|
padding: 1,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
}}>
|
}}>
|
||||||
<Icon type={attachment.type}/>
|
<AttachmentIcon type={attachment.type}/>
|
||||||
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
||||||
<b>{attachment.name}</b>
|
<b>{attachment.name}</b>
|
||||||
{maybeInfoText}
|
{maybeInfoText}
|
||||||
@@ -268,7 +270,7 @@ const Attachment = (props) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon type={attachment.type}/>
|
<AttachmentIcon type={attachment.type}/>
|
||||||
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
||||||
<b>{attachment.name}</b>
|
<b>{attachment.name}</b>
|
||||||
{maybeInfoText}
|
{maybeInfoText}
|
||||||
@@ -323,35 +325,6 @@ 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;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src={imageFile}
|
|
||||||
loading="lazy"
|
|
||||||
sx={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NoNotifications = (props) => {
|
const NoNotifications = (props) => {
|
||||||
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
||||||
return (
|
return (
|
||||||
|
|||||||
655
web/src/components/SendDialog.js
Normal file
655
web/src/components/SendDialog.js
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const SendDialog = (props) => {
|
||||||
|
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) {
|
||||||
|
const percent = Math.round(ev.loaded * 100.0 / ev.total);
|
||||||
|
setStatus(`Uploading ${formatBytes(ev.loaded)}/${formatBytes(ev.total)} (${percent}%) ...`);
|
||||||
|
} else {
|
||||||
|
setStatus(`Uploading ...`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const request = api.publishXHR(url, body, headers, progressFn);
|
||||||
|
setActiveRequest(request);
|
||||||
|
await request;
|
||||||
|
if (!publishAnother) {
|
||||||
|
props.onClose();
|
||||||
|
} else {
|
||||||
|
setStatus("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(`exceeds ${formatBytes(fileSizeLimit)} file limit and quota, ${formatBytes(remainingBytes)} remaining`);
|
||||||
|
} else if (fileSizeLimitReached) {
|
||||||
|
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit`);
|
||||||
|
} else if (quotaReached) {
|
||||||
|
return setAttachFileError(`exceeds quota, ${formatBytes(remainingBytes)} remaining`);
|
||||||
|
}
|
||||||
|
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 === SendDialog.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{dropZone && <DropArea
|
||||||
|
onDrop={handleAttachFileDrop}
|
||||||
|
onDragLeave={handleAttachFileDragLeave}/>
|
||||||
|
}
|
||||||
|
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{(baseUrl && topic) ? `Publish to ${topicShortUrl(baseUrl, topic)}` : "Publish message"}</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="Server URL"
|
||||||
|
placeholder="Server URL, e.g. https://example.com"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={ev => setBaseUrl(ev.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
type="url"
|
||||||
|
variant="standard"
|
||||||
|
sx={{flexGrow: 1, marginRight: 1}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Topic"
|
||||||
|
placeholder="Topic name, e.g. phil_alerts"
|
||||||
|
value={topic}
|
||||||
|
onChange={ev => setTopic(ev.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
type="text"
|
||||||
|
variant="standard"
|
||||||
|
autoFocus={!messageFocused}
|
||||||
|
sx={{flexGrow: 1}}
|
||||||
|
/>
|
||||||
|
</ClosableRow>
|
||||||
|
}
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={ev => setTitle(ev.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
placeholder="Notification title, e.g. Disk space alert"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Message"
|
||||||
|
placeholder="Type a message here"
|
||||||
|
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="Tags"
|
||||||
|
placeholder="Comma-separated list of tags, e.g. warning, srv1-backup"
|
||||||
|
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: 120, maxWidth: 200, flexGrow: 1}}
|
||||||
|
>
|
||||||
|
<InputLabel/>
|
||||||
|
<Select
|
||||||
|
label="Priority"
|
||||||
|
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="Click URL"
|
||||||
|
placeholder="URL that is opened when notification is clicked"
|
||||||
|
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="Email"
|
||||||
|
placeholder="Address to forward the message to, e.g. phil@example.com"
|
||||||
|
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="Attachment URL"
|
||||||
|
placeholder="Attach file by URL, e.g. https://f-droid.org/F-Droid.apk"
|
||||||
|
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="Filename"
|
||||||
|
placeholder="Attachment filename"
|
||||||
|
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="Delay"
|
||||||
|
placeholder="Delay delivery, e.g. 1649029748, 30m, or 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}}>
|
||||||
|
Other features:
|
||||||
|
</Typography>
|
||||||
|
<div>
|
||||||
|
{!showClickUrl && <Chip clickable disabled={disabled} label="Click URL" onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
{!showEmail && <Chip clickable disabled={disabled} label="Forward to email" onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label="Attach file by URL" onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label="Attach local file" onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
{!showDelay && <Chip clickable disabled={disabled} label="Delay delivery" onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
{!showTopicUrl && <Chip clickable disabled={disabled} label="Change topic" onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||||
|
</div>
|
||||||
|
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
|
||||||
|
For examples and a detailed description of all send features, please
|
||||||
|
refer to the <Link href="/docs" target="_blank">documentation</Link>.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={status}>
|
||||||
|
{activeRequest && <Button onClick={() => activeRequest.abort()}>Cancel sending</Button>}
|
||||||
|
{!activeRequest &&
|
||||||
|
<>
|
||||||
|
<FormControlLabel
|
||||||
|
label="Publish another"
|
||||||
|
sx={{marginRight: 2}}
|
||||||
|
control={
|
||||||
|
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
|
||||||
|
} />
|
||||||
|
<Button onClick={props.onClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>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 file = props.file;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="body1" sx={{marginTop: 2}}>
|
||||||
|
Attached file:
|
||||||
|
</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"
|
||||||
|
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="Attachment filename"
|
||||||
|
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 = () => {
|
||||||
|
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">Drop file here</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorities = {
|
||||||
|
1: { label: "Min. priority", file: priority1 },
|
||||||
|
2: { label: "Low priority", file: priority2 },
|
||||||
|
3: { label: "Default priority", file: priority3 },
|
||||||
|
4: { label: "High priority", file: priority4 },
|
||||||
|
5: { label: "Max. priority", file: priority5 }
|
||||||
|
};
|
||||||
|
|
||||||
|
SendDialog.OPEN_MODE_DEFAULT = "default";
|
||||||
|
SendDialog.OPEN_MODE_DRAG = "drag";
|
||||||
|
|
||||||
|
export default SendDialog;
|
||||||
@@ -3,7 +3,6 @@ import {useState} from 'react';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -11,10 +10,10 @@ import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/mate
|
|||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import {topicUrl, validTopic, validUrl} from "../app/utils";
|
import {topicUrl, validTopic, validUrl} from "../app/utils";
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
|
import DialogFooter from "./DialogFooter";
|
||||||
|
|
||||||
const publicBaseUrl = "https://ntfy.sh";
|
const publicBaseUrl = "https://ntfy.sh";
|
||||||
|
|
||||||
@@ -188,27 +187,4 @@ const LoginPage = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
export default SubscribeDialog;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import notifier from "../app/Notifier";
|
|||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import connectionManager from "../app/ConnectionManager";
|
import connectionManager from "../app/ConnectionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
|
import pruner from "../app/Pruner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
* 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
|
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
|
||||||
* from the old web UI.
|
* 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 = () => {
|
export const useBackgroundProcesses = () => {
|
||||||
const [hasRun, setHasRun] = useState(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRun) {
|
poller.startWorker();
|
||||||
return;
|
pruner.startWorker();
|
||||||
}
|
|
||||||
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);
|
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
1
web/src/img/priority-3.svg
Normal file
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 |
Reference in New Issue
Block a user