Compare commits

..

1 Commits

Author SHA1 Message Date
Philipp Heckel
3baa1a9fd0 Electron WIP, nothing works... 2022-05-05 20:49:48 -04:00
77 changed files with 7858 additions and 6503 deletions

View File

@@ -1,39 +0,0 @@
name: build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
-
name: Install node
uses: actions/setup-node@v2
with:
node-version: '16'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Install dependencies
run: make build-deps-ubuntu
-
name: Build all the things
run: make build
-
name: Print build results and checksums
run: make cli-build-results

View File

@@ -1,50 +0,0 @@
name: release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
-
name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
-
name: Install node
uses: actions/setup-node@v2
with:
node-version: '16'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Docker login
uses: docker/login-action@v2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
-
name: Install dependencies
run: make build-deps-ubuntu
-
name: Build and publish
run: make release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
name: Print build results and checksums
run: make cli-build-results

View File

@@ -3,46 +3,26 @@ on: [push, pull_request]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- - name: Install Go
name: Install Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: '1.18.x' go-version: '1.17.x'
- - name: Install node
name: Install node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '16'
- - name: Checkout code
name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- - name: Install dependencies
name: Cache Go and npm modules run: sudo apt update && sudo apt install -y python3-pip curl
uses: actions/cache@v3 - name: Build docs (required for tests)
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Install dependencies
run: make build-deps-ubuntu
-
name: Build docs (required for tests)
run: make docs run: make docs
- - name: Build web app (required for tests)
name: Build web app (required for tests)
run: make web run: make web
- - name: Run tests, formatting, vetting and linting
name: Run tests, formatting, vetting and linting
run: make check run: make check
- - name: Run coverage
name: Run coverage
run: make coverage run: make coverage
- - name: Upload coverage to codecov.io
name: Upload coverage to codecov.io
run: make coverage-upload run: make coverage-upload

4
.gitignore vendored
View File

@@ -1,13 +1,9 @@
dist/ dist/
build/ build/
.idea/ .idea/
.vscode/
*.swp
server/docs/ server/docs/
server/site/ server/site/
tools/fbsend/fbsend tools/fbsend/fbsend
playground/ playground/
secrets/
*.iml *.iml
node_modules/ node_modules/
.DS_Store

View File

@@ -4,7 +4,7 @@ before:
- go mod tidy - go mod tidy
builds: builds:
- -
id: ntfy_linux_amd64 id: ntfy_amd64
binary: ntfy binary: ntfy
env: env:
- CGO_ENABLED=1 # required for go-sqlite3 - CGO_ENABLED=1 # required for go-sqlite3
@@ -17,7 +17,7 @@ builds:
post: post:
- upx "{{ .Path }}" # apt install upx - upx "{{ .Path }}" # apt install upx
- -
id: ntfy_linux_armv6 id: ntfy_armv6
binary: ntfy binary: ntfy
env: env:
- CGO_ENABLED=1 # required for go-sqlite3 - CGO_ENABLED=1 # required for go-sqlite3
@@ -28,9 +28,10 @@ builds:
goos: [linux] goos: [linux]
goarch: [arm] goarch: [arm]
goarm: [6] goarm: [6]
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546 # No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
- -
id: ntfy_linux_armv7 id: ntfy_armv7
binary: ntfy binary: ntfy
env: env:
- CGO_ENABLED=1 # required for go-sqlite3 - CGO_ENABLED=1 # required for go-sqlite3
@@ -41,9 +42,10 @@ builds:
goos: [linux] goos: [linux]
goarch: [arm] goarch: [arm]
goarm: [7] goarm: [7]
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546 # No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
- -
id: ntfy_linux_arm64 id: ntfy_arm64
binary: ntfy binary: ntfy
env: env:
- CGO_ENABLED=1 # required for go-sqlite3 - CGO_ENABLED=1 # required for go-sqlite3
@@ -53,30 +55,8 @@ builds:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux] goos: [linux]
goarch: [arm64] goarch: [arm64]
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546 # No "upx", since it causes random core dumps, see
- # https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
id: ntfy_windows_amd64
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
tags: [noserver] # don't include server files
ldflags:
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [windows]
goarch: [amd64]
hooks:
post:
- upx "{{ .Path }}" # apt install upx
-
id: ntfy_darwin_all
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
tags: [noserver] # don't include server files
ldflags:
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [darwin]
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
nfpms: nfpms:
- -
package_name: ntfy package_name: ntfy
@@ -114,12 +94,6 @@ nfpms:
postremove: "scripts/postrm.sh" postremove: "scripts/postrm.sh"
archives: archives:
- -
id: ntfy_linux
builds:
- ntfy_linux_amd64
- ntfy_linux_armv6
- ntfy_linux_armv7
- ntfy_linux_arm64
wrap_in_directory: true wrap_in_directory: true
files: files:
- LICENSE - LICENSE
@@ -129,35 +103,8 @@ archives:
- client/client.yml - client/client.yml
- client/ntfy-client.service - client/ntfy-client.service
replacements: replacements:
386: i386
amd64: x86_64 amd64: x86_64
-
id: ntfy_windows
builds:
- ntfy_windows_amd64
format: zip
wrap_in_directory: true
files:
- LICENSE
- README.md
- client/client.yml
replacements:
amd64: x86_64
-
id: ntfy_darwin
builds:
- ntfy_darwin_all
wrap_in_directory: true
files:
- LICENSE
- README.md
- client/client.yml
replacements:
darwin: macOS
universal_binaries:
-
id: ntfy_darwin_all
replace: true
name_template: ntfy
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
snapshot: snapshot:

236
Makefile
View File

@@ -1,72 +1,64 @@
MAKEFLAGS := --jobs=1
VERSION := $(shell git describe --tag) VERSION := $(shell git describe --tag)
.PHONY: .PHONY:
help: help:
@echo "Typical commands (more see below):" @echo "Typical commands (more see below):"
@echo " make build - Build web app, documentation and server/client (sloowwww)" @echo " make build - Build web app, documentation and server/client (sloowwww)"
@echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)" @echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)"
@echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)" @echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
@echo " make web - Build the web app" @echo " make web - Build the web app"
@echo " make docs - Build the documentation" @echo " make docs - Build the documentation"
@echo " make check - Run all tests, vetting/formatting checks and linters" @echo " make check - Run all tests, vetting/formatting checks and linters"
@echo @echo
@echo "Build everything:" @echo "Build everything:"
@echo " make build - Build web app, documentation and server/client" @echo " make build - Build web app, documentation and server/client"
@echo " make clean - Clean build/dist folders" @echo " make clean - Clean build/dist folders"
@echo @echo
@echo "Build server & client (using GoReleaser, not release version):" @echo "Build server & client (not release version):"
@echo " make cli - Build server & client (all architectures)" @echo " make server - Build server & client (all architectures)"
@echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)" @echo " make server-amd64 - Build server & client (amd64 only)"
@echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)" @echo " make server-armv6 - Build server & client (armv6 only)"
@echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)" @echo " make server-armv7 - Build server & client (armv7 only)"
@echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)" @echo " make server-arm64 - Build server & client (arm64 only)"
@echo " make cli-windows-amd64 - Build client (Windows, amd64 only)"
@echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)"
@echo
@echo "Build server & client (without GoReleaser):"
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
@echo @echo
@echo "Build web app:" @echo "Build web app:"
@echo " make web - Build the web app" @echo " make web - Build the web app"
@echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app" @echo " make web-build - Actually build the web app"
@echo @echo
@echo "Build documentation:" @echo "Build documentation:"
@echo " make docs - Build the documentation" @echo " make docs - Build the documentation"
@echo " make docs-deps - Install Python dependencies (pip3 install)" @echo " make docs-deps - Install Python dependencies (pip3 install)"
@echo " make docs-build - Actually build the documentation" @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 "Releasing:" @echo "Releasing:"
@echo " make release - Create a release" @echo " make release - Create a release"
@echo " make release-snapshot - Create a test release" @echo " make release-snapshot - Create a test release"
@echo @echo
@echo "Install locally (requires sudo):" @echo "Install locally (requires sudo):"
@echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy" @echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
@echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy" @echo " make install-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
@echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy" @echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
@echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy" @echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
@echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)" @echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)"
@echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)" @echo " make install-deb-armv6 - Install .deb from dist/ (armv6 only)"
@echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)" @echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
@echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)" @echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
# Building everything # Building everything
@@ -74,41 +66,28 @@ help:
clean: .PHONY clean: .PHONY
rm -rf dist build server/docs server/site rm -rf dist build server/docs server/site
build: web docs cli build: web docs server
update: web-deps-update cli-deps-update docs-deps-update
docker pull alpine
# Ubuntu-specific
build-deps-ubuntu:
sudo apt update
sudo apt install -y \
curl \
gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \
upx \
jq
which pip3 || sudo apt install -y python3-pip
# Documentation # Documentation
docs: docs-deps docs-build docs: docs-deps docs-build
docs-build: .PHONY
mkdocs build
docs-deps: .PHONY docs-deps: .PHONY
pip3 install -r requirements.txt pip3 install -r requirements.txt
docs-deps-update: .PHONY docs-build: .PHONY
pip3 install -r requirements.txt --upgrade mkdocs build
# Web app # Web app
web: web-deps web-build web: web-deps web-build
web-deps:
cd web && npm install
# If this fails for .svg files, optimizes them with svgo
web-build: web-build:
cd web \ cd web \
&& npm run build \ && npm run build \
@@ -119,100 +98,41 @@ web-build:
../server/site/config.js \ ../server/site/config.js \
../server/site/asset-manifest.json ../server/site/asset-manifest.json
web-deps:
cd web && npm install
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && npm update
# Main server/client build # Main server/client build
cli: cli-deps server: server-deps
goreleaser build --snapshot --rm-dist goreleaser build --snapshot --rm-dist --debug
cli-linux-amd64: cli-deps-static-sites server-amd64: server-deps-static-sites
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64 goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6 goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7 goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 server-arm64: server-deps-static-sites server-deps-gcc-arm64
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64 goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
cli-windows-amd64: cli-deps-static-sites server-deps: server-deps-static-sites server-deps-all server-deps-gcc
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
cli-darwin-all: cli-deps-static-sites server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
cli-linux-server: cli-deps-static-sites server-deps-static-sites:
# This is a target to build the CLI (including the server) manually.
# Use this for development, if you really don't want to install GoReleaser ...
mkdir -p dist/ntfy_linux_server server/docs
CGO_ENABLED=1 go build \
-o dist/ntfy_linux_server/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
cli-darwin-server: cli-deps-static-sites
# This is a target to build the CLI (including the server) manually.
# Use this for macOS/iOS development, so you have a local server to test with.
mkdir -p dist/ntfy_darwin_server server/docs
CGO_ENABLED=1 go build \
-o dist/ntfy_darwin_server/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
cli-client: cli-deps-static-sites
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
# Use this for development, if you really don't want to install GoReleaser ...
mkdir -p dist/ntfy_client server/docs
CGO_ENABLED=0 go build \
-o dist/ntfy_client/ntfy \
-tags noserver \
-ldflags \
"-X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
cli-deps-static-sites:
mkdir -p server/docs server/site mkdir -p server/docs server/site
touch server/docs/index.html server/site/app.html touch server/docs/index.html server/site/app.html
cli-deps-all: server-deps-all:
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; } which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
go install github.com/goreleaser/goreleaser@latest
cli-deps-gcc-armv6-armv7: server-deps-gcc-armv6-armv7:
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
cli-deps-gcc-arm64: 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; } which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
cli-deps-update:
go get -u
go install honnef.co/go/tools/cmd/staticcheck@latest
go install golang.org/x/lint/golint@latest
go install github.com/goreleaser/goreleaser@latest
cli-build-results:
cat dist/config.yaml
[ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true
[ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true
[ -f dist/checksums.txt ] && cat dist/checksums.txt || true
find dist -maxdepth 2 -type f \
\( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \
-and -not -path 'dist/goreleaserdocker*' \
-exec sha256sum {} \;
# Test/check targets # Test/check targets
@@ -264,13 +184,13 @@ staticcheck: .PHONY
# Releasing targets # Releasing targets
release: clean update cli-deps release-checks docs web check release: clean server-deps release-check-tags docs web check
goreleaser release --rm-dist goreleaser release --rm-dist --debug
release-snapshot: clean update cli-deps docs web check release-snapshot: clean server-deps docs web check
goreleaser release --snapshot --skip-publish --rm-dist goreleaser release --snapshot --skip-publish --rm-dist --debug
release-checks: 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.";\
@@ -280,39 +200,35 @@ release-checks:
echo "ERROR: Must update docs/releases.md with latest tag first.";\ echo "ERROR: Must update docs/releases.md with latest tag first.";\
exit 1;\ exit 1;\
fi fi
if [ -n "$(shell git status -s)" ]; then\
echo "ERROR: Git repository is in an unclean state.";\
exit 1;\
fi
# Installing targets # Installing targets
install-linux-amd64: remove-binary install-amd64: remove-binary
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
install-linux-armv6: remove-binary install-armv6: remove-binary
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
install-linux-armv7: remove-binary install-armv7: remove-binary
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
install-linux-arm64: remove-binary install-arm64: remove-binary
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
remove-binary: remove-binary:
sudo rm -f /usr/bin/ntfy sudo rm -f /usr/bin/ntfy
install-linux-amd64-deb: purge-package install-amd64-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_amd64.deb sudo dpkg -i dist/ntfy_*_linux_amd64.deb
install-linux-armv6-deb: purge-package install-armv6-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_armv6.deb sudo dpkg -i dist/ntfy_*_linux_armv6.deb
install-linux-armv7-deb: purge-package install-armv7-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_armv7.deb sudo dpkg -i dist/ntfy_*_linux_armv7.deb
install-linux-arm64-deb: purge-package install-arm64-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_arm64.deb sudo dpkg -i dist/ntfy_*_linux_arm64.deb
purge-package: purge-package:

View File

@@ -33,16 +33,6 @@ too.
[Install / Self-hosting](https://ntfy.sh/docs/install/) | [Install / Self-hosting](https://ntfy.sh/docs/install/) |
[Building](https://ntfy.sh/docs/develop/) [Building](https://ntfy.sh/docs/develop/)
## Chat
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
[on my website](https://heckel.io/about).
## Announcements / beta testers
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
join Discord/Matrix (I'll eventually make a testing channel in Google Play).
## Contributing ## Contributing
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out 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. the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
@@ -53,6 +43,11 @@ Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start im
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" /> <img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
</a> </a>
## Contact me
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
[on my website](https://heckel.io/about).
## License ## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io). Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2). The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).

View File

@@ -7,9 +7,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"log"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -102,7 +102,6 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
return nil, err return nil, err
} }
} }
log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -137,7 +136,6 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
msgChan := make(chan *Message) msgChan := make(chan *Message)
errChan := make(chan error) errChan := make(chan error)
topicURL := c.expandTopicURL(topic) topicURL := c.expandTopicURL(topic)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll()) options = append(options, WithPoll())
go func() { go func() {
err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
@@ -173,7 +171,6 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
defer c.mu.Unlock() defer c.mu.Unlock()
subscriptionID := util.RandomString(10) subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic) topicURL := c.expandTopicURL(topic)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{ c.subscriptions[subscriptionID] = &subscription{
ID: subscriptionID, ID: subscriptionID,
@@ -229,11 +226,11 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
// TODO The retry logic is crude and may lose messages. It should record the last message like the // TODO The retry logic is crude and may lose messages. It should record the last message like the
// Android client, use since=, and do incremental backoff too // Android client, use since=, and do incremental backoff too
if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {
log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error()) log.Printf("Connection to %s failed: %s", topicURL, err.Error())
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Info("%s Connection exited", util.ShortTopicURL(topicURL)) log.Printf("Connection to %s exited", topicURL)
return return
case <-time.After(10 * time.Second): // TODO Add incremental backoff case <-time.After(10 * time.Second): // TODO Add incremental backoff
} }
@@ -241,9 +238,7 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
} }
func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {
streamURL := fmt.Sprintf("%s/json", topicURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil)
log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -266,12 +261,10 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
} }
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() { for scanner.Scan() {
messageJSON := scanner.Text() m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
m, err := toMessage(messageJSON, topicURL, subscriptionID)
if err != nil { if err != nil {
return err return err
} }
log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON)
if m.Event == MessageEvent { if m.Event == MessageEvent {
msgChan <- m msgChan <- m
} }

View File

@@ -1,5 +1,3 @@
//go:build !noserver
package cmd package cmd
import ( import (
@@ -10,16 +8,12 @@ import (
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
) )
func init() {
commands = append(commands, cmdAccess)
}
const ( const (
userEveryone = "everyone" userEveryone = "everyone"
) )
var flagsAccess = append( var flagsAccess = append(
flagsUser, userCommandFlags(),
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, &cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
) )
@@ -28,7 +22,7 @@ var cmdAccess = &cli.Command{
Usage: "Grant/revoke access to a topic, or show access", Usage: "Grant/revoke access to a topic, or show access",
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
Flags: flagsAccess, Flags: flagsAccess,
Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc), Before: initConfigFileInputSource("config", flagsAccess),
Action: execUserAccess, Action: execUserAccess,
Category: categoryServer, Category: categoryServer,
Description: `Manage the access control list for the ntfy server. Description: `Manage the access control list for the ntfy server.

View File

@@ -2,26 +2,23 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/log" "heckel.io/ntfy/util"
"os" "os"
) )
var (
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
)
const ( const (
categoryClient = "Client commands" categoryClient = "Client commands"
categoryServer = "Server commands" categoryServer = "Server commands"
) )
var commands = make([]*cli.Command, 0)
var flagsDefault = []cli.Flag{
&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"},
&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"},
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
}
// New creates a new CLI application // New creates a new CLI application
func New() *cli.App { func New() *cli.App {
return &cli.App{ return &cli.App{
@@ -33,22 +30,33 @@ func New() *cli.App {
Reader: os.Stdin, Reader: os.Stdin,
Writer: os.Stdout, Writer: os.Stdout,
ErrWriter: os.Stderr, ErrWriter: os.Stderr,
Commands: commands, Commands: []*cli.Command{
Flags: flagsDefault, // Server commands
Before: initLogFunc, cmdServe,
cmdUser,
cmdAccess,
// Client commands
cmdPublish,
cmdSubscribe,
},
} }
} }
func initLogFunc(c *cli.Context) error { // initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
if c.Bool("trace") { // if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
log.SetLevel(log.TraceLevel) func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
} else if c.Bool("debug") { return func(context *cli.Context) error {
log.SetLevel(log.DebugLevel) configFile := context.String(configFlag)
} else { if context.IsSet(configFlag) && !util.FileExists(configFile) {
log.SetLevel(log.ToLevel(c.String("log-level"))) return fmt.Errorf("config file %s does not exist", configFile)
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
return nil
}
inputSource, err := altsrc.NewYamlSourceFromFile(configFile)
if err != nil {
return err
}
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
} }
if c.Bool("no-log-dates") {
log.DisableDates()
}
return nil
} }

View File

@@ -1,60 +0,0 @@
package cmd
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/util"
"os"
)
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc {
return func(context *cli.Context) error {
configFile := context.String(configFlag)
if context.IsSet(configFlag) && !util.FileExists(configFile) {
return fmt.Errorf("config file %s does not exist", configFile)
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
return nil
}
inputSource, err := newYamlSourceFromFile(configFile, flags)
if err != nil {
return err
}
if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil {
return err
}
if next != nil {
if err := next(context); err != nil {
return err
}
}
return nil
}
}
// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath.
//
// This function also maps aliases, so a .yml file can contain short options, or options with underscores
// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255.
func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {
var rawConfig map[interface{}]interface{}
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(b, &rawConfig); err != nil {
return nil, err
}
for _, f := range flags {
flagName := f.Names()[0]
for _, flagAlias := range f.Names()[1:] {
if _, ok := rawConfig[flagAlias]; ok {
rawConfig[flagName] = rawConfig[flagAlias]
}
}
}
return altsrc.NewMapInputSource(file, rawConfig), nil
}

View File

@@ -1,38 +0,0 @@
package cmd
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
)
func TestNewYamlSourceFromFile(t *testing.T) {
filename := filepath.Join(t.TempDir(), "server.yml")
contents := `
# Normal options
listen-https: ":10443"
# Note the underscore!
listen_http: ":1080"
# OMG this is allowed now ...
K: /some/file.pem
`
require.Nil(t, os.WriteFile(filename, []byte(contents), 0600))
ctx, err := newYamlSourceFromFile(filename, flagsServe)
require.Nil(t, err)
listenHTTPS, err := ctx.String("listen-https")
require.Nil(t, err)
require.Equal(t, ":10443", listenHTTPS)
listenHTTP, err := ctx.String("listen-http") // No underscore!
require.Nil(t, err)
require.Equal(t, ":1080", listenHTTP)
keyFile, err := ctx.String("key-file") // Long option!
require.Nil(t, err)
require.Equal(t, "/some/file.pem", keyFile)
}

View File

@@ -12,39 +12,31 @@ import (
"strings" "strings"
) )
func init() {
commands = append(commands, cmdPublish)
}
var flagsPublish = append(
flagsDefault,
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
)
var cmdPublish = &cli.Command{ var cmdPublish = &cli.Command{
Name: "publish", Name: "publish",
Aliases: []string{"pub", "send", "trigger"}, Aliases: []string{"pub", "send", "trigger"},
Usage: "Send message via a ntfy server", Usage: "Send message via a ntfy server",
UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]", UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
Action: execPublish, Action: execPublish,
Category: categoryClient, Category: categoryClient,
Flags: flagsPublish, Flags: []cli.Flag{
Before: initLogFunc, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
},
Description: `Publish a message to a ntfy server. Description: `Publish a message to a ntfy server.
Examples: Examples:
@@ -67,7 +59,8 @@ Examples:
Please also check out the docs on publishing messages. Especially for the --tags and --delay options, Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
it has incredibly useful information: https://ntfy.sh/docs/publish/. it has incredibly useful information: https://ntfy.sh/docs/publish/.
` + clientCommandDescriptionSuffix, The default config file for all client commands is /etc/ntfy/client.yml (if root user),
or ~/.config/ntfy/client.yml for all other users.`,
} }
func execPublish(c *cli.Context) error { func execPublish(c *cli.Context) error {

View File

@@ -1,74 +1,58 @@
//go:build !noserver
package cmd package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/log"
"math"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/server" "heckel.io/ntfy/server"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"log"
"math"
"net"
"strings"
"time"
) )
func init() { var flagsServe = []cli.Flag{
commands = append(commands, cmdServe) &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
} }
const (
defaultServerConfigFile = "/etc/ntfy/server.yml"
)
var flagsServe = append(
flagsDefault,
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
)
var cmdServe = &cli.Command{ var cmdServe = &cli.Command{
Name: "serve", Name: "serve",
Usage: "Run the ntfy server", Usage: "Run the ntfy server",
@@ -76,7 +60,7 @@ var cmdServe = &cli.Command{
Action: execServe, Action: execServe,
Category: categoryServer, Category: categoryServer,
Flags: flagsServe, Flags: flagsServe,
Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc), Before: initConfigFileInputSource("config", flagsServe),
Description: `Run the ntfy server and listen for incoming requests Description: `Run the ntfy server and listen for incoming requests
The command will load the configuration from /etc/ntfy/server.yml. Config options can The command will load the configuration from /etc/ntfy/server.yml. Config options can
@@ -93,7 +77,6 @@ func execServe(c *cli.Context) error {
} }
// Read all the options // Read all the options
config := c.String("config")
baseURL := c.String("base-url") baseURL := c.String("base-url")
listenHTTP := c.String("listen-http") listenHTTP := c.String("listen-http")
listenHTTPS := c.String("listen-https") listenHTTPS := c.String("listen-https")
@@ -112,7 +95,6 @@ func execServe(c *cli.Context) error {
keepaliveInterval := c.Duration("keepalive-interval") keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval") managerInterval := c.Duration("manager-interval")
webRoot := c.String("web-root") webRoot := c.String("web-root")
upstreamBaseURL := c.String("upstream-base-url")
smtpSenderAddr := c.String("smtp-sender-addr") smtpSenderAddr := c.String("smtp-sender-addr")
smtpSenderUser := c.String("smtp-sender-user") smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass") smtpSenderPass := c.String("smtp-sender-pass")
@@ -156,18 +138,12 @@ func execServe(c *cli.Context) error {
return errors.New("if set, base-url must start with http:// or https://") return errors.New("if set, base-url must start with http:// or https://")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { } else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) { } else if !util.InStringList([]string{"app", "home"}, webRoot) {
return errors.New("if set, web-root must be 'home' or 'app'") return errors.New("if set, web-root must be 'home' or 'app'")
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && baseURL == "" {
return errors.New("if upstream-base-url is set, base-url must also be set")
} }
webRootIsApp := webRoot == "app"
enableWeb := webRoot != "disable"
// Default auth permissions // Default auth permissions
webRootIsApp := webRoot == "app"
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
@@ -201,7 +177,7 @@ func execServe(c *cli.Context) error {
for _, host := range visitorRequestLimitExemptHosts { for _, host := range visitorRequestLimitExemptHosts {
ips, err := net.LookupIP(host) ips, err := net.LookupIP(host)
if err != nil { if err != nil {
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
continue continue
} }
for _, ip := range ips { for _, ip := range ips {
@@ -230,7 +206,6 @@ func execServe(c *cli.Context) error {
conf.KeepaliveInterval = keepaliveInterval conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval conf.ManagerInterval = managerInterval
conf.WebRootIsApp = webRootIsApp conf.WebRootIsApp = webRootIsApp
conf.UpstreamBaseURL = upstreamBaseURL
conf.SMTPSenderAddr = smtpSenderAddr conf.SMTPSenderAddr = smtpSenderAddr
conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderPass = smtpSenderPass
@@ -248,19 +223,14 @@ func execServe(c *cli.Context) error {
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.BehindProxy = behindProxy conf.BehindProxy = behindProxy
conf.EnableWeb = enableWeb
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
// Run server
s, err := server.New(conf) s, err := server.New(conf)
if err != nil { if err != nil {
log.Fatal(err) log.Fatalln(err)
} else if err := s.Run(); err != nil {
log.Fatal(err)
} }
log.Info("Exiting.") if err := s.Run(); err != nil {
log.Fatalln(err)
}
log.Printf("Exiting.")
return nil return nil
} }
@@ -274,28 +244,3 @@ func parseSize(s string, defaultValue int64) (v int64, err error) {
} }
return v, nil return v, nil
} }
func sigHandlerConfigReload(config string) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)
for range sigs {
log.Info("Partially hot reloading configuration ...")
inputSource, err := newYamlSourceFromFile(config, flagsServe)
if err != nil {
log.Warn("Hot reload failed: %s", err.Error())
continue
}
reloadLogLevel(inputSource)
}
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) {
newLevelStr, err := inputSource.String("log-level")
if err != nil {
log.Warn("Cannot load log level: %s", err.Error())
return
}
newLevel := log.ToLevel(newLevelStr)
log.SetLevel(newLevel)
log.Info("Log level is %s", newLevel.String())
}

View File

@@ -5,36 +5,14 @@ import (
"fmt" "fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/client" "heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"log"
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"
"path/filepath"
"sort"
"strings" "strings"
) )
func init() {
commands = append(commands, cmdSubscribe)
}
const (
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
clientUserConfigFileUnixRelative = "ntfy/client.yml"
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
)
var flagsSubscribe = append(
flagsDefault,
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
)
var cmdSubscribe = &cli.Command{ var cmdSubscribe = &cli.Command{
Name: "subscribe", Name: "subscribe",
Aliases: []string{"sub"}, Aliases: []string{"sub"},
@@ -42,8 +20,15 @@ var cmdSubscribe = &cli.Command{
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
Action: execSubscribe, Action: execSubscribe,
Category: categoryClient, Category: categoryClient,
Flags: flagsSubscribe, Flags: []cli.Flag{
Before: initLogFunc, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
},
Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
every arriving message. There are 3 modes in which the command can be run: every arriving message. There are 3 modes in which the command can be run:
@@ -75,17 +60,19 @@ ntfy subscribe TOPIC COMMAND
Examples: Examples:
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
ntfy sub topic1 myscript.sh # Execute script for incoming messages ntfy sub topic1 /my/script.sh # Execute script for incoming messages
ntfy subscribe --from-config ntfy subscribe --from-config
Service mode (used in ntfy-client.service). This reads the config file and sets up Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
subscriptions for every topic in the "subscribe:" block (see config file). or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
block (see config file).
Examples: Examples:
ntfy sub --from-config # Read topics from config file ntfy sub --from-config # Read topics from config file
ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
` + clientCommandDescriptionSuffix, The default config file for all client commands is /etc/ntfy/client.yml (if root user),
or ~/.config/ntfy/client.yml for all other users.`,
} }
func execSubscribe(c *cli.Context) error { func execSubscribe(c *cli.Context) error {
@@ -169,8 +156,8 @@ func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, opti
} }
func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
cmds := make(map[string]string) // Subscription ID -> command commands := make(map[string]string) // Subscription ID -> command
for _, s := range conf.Subscribe { // May be nil for _, s := range conf.Subscribe { // May be nil
topicOptions := append(make([]client.SubscribeOption, 0), options...) topicOptions := append(make([]client.SubscribeOption, 0), options...)
for filter, value := range s.If { for filter, value := range s.If {
topicOptions = append(topicOptions, client.WithFilter(filter, value)) topicOptions = append(topicOptions, client.WithFilter(filter, value))
@@ -179,19 +166,18 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password)) topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
} }
subscriptionID := cl.Subscribe(s.Topic, topicOptions...) subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
cmds[subscriptionID] = s.Command commands[subscriptionID] = s.Command
} }
if topic != "" { if topic != "" {
subscriptionID := cl.Subscribe(topic, options...) subscriptionID := cl.Subscribe(topic, options...)
cmds[subscriptionID] = command commands[subscriptionID] = command
} }
for m := range cl.Messages { for m := range cl.Messages {
cmd, ok := cmds[m.SubscriptionID] command, ok := commands[m.SubscriptionID]
if !ok { if !ok {
continue continue
} }
log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw) printMessageOrRunCommand(c, m, command)
printMessageOrRunCommand(c, m, cmd)
} }
return nil return nil
} }
@@ -200,27 +186,27 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string)
if command != "" { if command != "" {
runCommand(c, command, m) runCommand(c, command, m)
} else { } else {
log.Debug("%s Printing raw message", logMessagePrefix(m))
fmt.Fprintln(c.App.Writer, m.Raw) fmt.Fprintln(c.App.Writer, m.Raw)
} }
} }
func runCommand(c *cli.Context, command string, m *client.Message) { func runCommand(c *cli.Context, command string, m *client.Message) {
if err := runCommandInternal(c, command, m); err != nil { if err := runCommandInternal(c, command, m); err != nil {
log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error()) fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error())
} }
} }
func runCommandInternal(c *cli.Context, script string, m *client.Message) error { func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt) scriptFile, err := createTmpScript(command)
log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile) if err != nil {
script = scriptHeader + script
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
return err return err
} }
defer os.Remove(scriptFile) defer os.Remove(scriptFile)
log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile) verbose := c.Bool("verbose")
cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...) if verbose {
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
}
cmd := exec.Command("sh", "-c", scriptFile)
cmd.Stdin = c.App.Reader cmd.Stdin = c.App.Reader
cmd.Stdout = c.App.Writer cmd.Stdout = c.App.Writer
cmd.Stderr = c.App.ErrWriter cmd.Stderr = c.App.ErrWriter
@@ -228,8 +214,17 @@ func runCommandInternal(c *cli.Context, script string, m *client.Message) error
return cmd.Run() return cmd.Run()
} }
func createTmpScript(command string) (string, error) {
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
script := fmt.Sprintf("#!/bin/sh\n%s", command)
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
return "", err
}
return scriptFile, nil
}
func envVars(m *client.Message) []string { func envVars(m *client.Message) []string {
env := make([]string, 0) env := os.Environ()
env = append(env, envVar(m.ID, "NTFY_ID", "id")...) env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
@@ -238,11 +233,7 @@ func envVars(m *client.Message) []string {
env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
sort.Strings(env) return env
if log.IsTrace() {
log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
}
return append(os.Environ(), env...)
} }
func envVar(value string, vars ...string) []string { func envVar(value string, vars ...string) []string {
@@ -258,30 +249,13 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
if filename != "" { if filename != "" {
return client.LoadConfig(filename) return client.LoadConfig(filename)
} }
configFile := defaultClientConfigFile() u, _ := user.Current()
configFile := defaultClientRootConfigFile
if u.Uid != "0" {
configFile = util.ExpandHome(defaultClientUserConfigFile)
}
if s, _ := os.Stat(configFile); s != nil { if s, _ := os.Stat(configFile); s != nil {
return client.LoadConfig(configFile) return client.LoadConfig(configFile)
} }
return client.NewConfig(), nil return client.NewConfig(), nil
} }
//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileUnix() string {
u, _ := user.Current()
configFile := clientRootConfigFileUnixAbsolute
if u.Uid != "0" {
homeDir, _ := os.UserConfigDir()
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
}
return configFile
}
//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileWindows() string {
homeDir, _ := os.UserConfigDir()
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
}
func logMessagePrefix(m *client.Message) string {
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
}

View File

@@ -1,16 +0,0 @@
package cmd
const (
scriptExt = "sh"
scriptHeader = "#!/bin/sh\n"
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
or "~/Library/Application Support/ntfy/client.yml" for all other users.`
)
var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() string {
return defaultClientConfigFileUnix()
}

View File

@@ -1,16 +0,0 @@
package cmd
const (
scriptExt = "sh"
scriptHeader = "#!/bin/sh\n"
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
or ~/.config/ntfy/client.yml for all other users.`
)
var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() string {
return defaultClientConfigFileUnix()
}

View File

@@ -1,15 +0,0 @@
package cmd
const (
scriptExt = "bat"
scriptHeader = ""
clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.`
)
var (
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
)
func defaultClientConfigFile() string {
return defaultClientConfigFileWindows()
}

View File

@@ -1,5 +1,3 @@
//go:build !noserver
package cmd package cmd
import ( import (
@@ -13,23 +11,13 @@ import (
"strings" "strings"
) )
func init() { var flagsUser = userCommandFlags()
commands = append(commands, cmdUser)
}
var flagsUser = append(
flagsDefault,
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
)
var cmdUser = &cli.Command{ var cmdUser = &cli.Command{
Name: "user", Name: "user",
Usage: "Manage/show users", Usage: "Manage/show users",
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
Flags: flagsUser, Flags: flagsUser,
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Before: initConfigFileInputSource("config", flagsUser),
Category: categoryServer, Category: categoryServer,
Subcommands: []*cli.Command{ Subcommands: []*cli.Command{
{ {
@@ -274,3 +262,11 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) {
} }
return string(password), nil return string(password), nil
} }
func userCommandFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
}
}

View File

@@ -1,15 +0,0 @@
version: "2.1"
services:
ntfy:
image: binwiederhier/ntfy
container_name: ntfy
command:
- serve
user: UID:GID # optional. Set custom user/group or uid/gid
volumes:
- /var/cache/ntfy:/var/cache/ntfy
- /etc/ntfy:/etc/ntfy
ports:
- 80:80
restart: unless-stopped

View File

@@ -227,7 +227,7 @@ The easiest way to configure a private instance is to set `auth-default-access`
=== "/etc/ntfy/server.yml" === "/etc/ntfy/server.yml"
``` yaml ``` yaml
auth-file: "/var/lib/ntfy/user.db" auth-file "/var/lib/ntfy/user.db"
auth-default-access: "deny-all" auth-default-access: "deny-all"
``` ```
@@ -618,43 +618,6 @@ Example:
firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
``` ```
## iOS instant notifications
Unlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant
push notifications without a central server.
To still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request`
messages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app),
which will then forward it to Firebase/APNS.
To configure it, simply set `upstream-base-url` like so:
``` yaml
upstream-base-url: "https://ntfy.sh"
```
If set, all incoming messages will publish a poll request to the configured upstream server, containing
the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
If `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours,
depending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though.
In case you're curious, here's an example of the entire flow:
- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic`
- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL)
- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a
poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server
contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic).
- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device
- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it
Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl:
```
curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b
{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"}
```
## Rate limiting ## Rate limiting
!!! info !!! info
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
@@ -708,23 +671,6 @@ are enabled):
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
### Firebase limits
If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no`
is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling)
on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time
of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to
the same topic**.
In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message
is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously,
there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected.
After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor.
If this ever happens, there will be a log message that looks something like this:
```
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
```
## Tuning for scale ## Tuning for scale
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
@@ -824,74 +770,48 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
maxretry = 10 maxretry = 10
``` ```
## Debugging/tracing
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
contents. The `TRACE` setting will also print the message contents.
!!! warning
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
you're going to run out of disk space pretty quickly.
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
If successful, you'll see something like this:
```
$ ntfy serve
2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO
2022/06/02 10:29:34 INFO Partially hot reloading configuration ...
2022/06/02 10:29:34 INFO Log level is TRACE
```
## Config options ## Config options
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
!!! info | Config option | Env variable | Format | Default | Description |
All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g. |--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
`cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do | `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
not support dashes. | `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
| Config option | Env variable | Format | Default | Description | | `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | | `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | | `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | | `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | | `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). | | `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | | `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | | `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). | | `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | | `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | | `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | | `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | | `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | | `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | | `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | | `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | | `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | | `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | | `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | | `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | | `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | | `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | | `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h. The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k. The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
@@ -919,46 +839,42 @@ DESCRIPTION:
ntfy serve --listen-http :8080 # Starts server with alternate port ntfy serve --listen-http :8080 # Starts server with alternate port
OPTIONS: OPTIONS:
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] --base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] --listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] --listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] --listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] --key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] --cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] --firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] --cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] --cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] --auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] --auth-default-access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG] --attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] --attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] --attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] --attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] --keepalive-interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] --manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] --web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX] --smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL] --smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] --smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES] --smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] --smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] --smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] --smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] --global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] --visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] --visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] --visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE] --visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL] --visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] --visitor-request-limit-exempt-hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] --visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] --visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] --behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] --help, -h show help (default: false)
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
``` ```

View File

@@ -112,15 +112,15 @@ by typing `make`:
$ make $ make
Typical commands (more see below): Typical commands (more see below):
make build - Build web app, documentation and server/client (sloowwww) make build - Build web app, documentation and server/client (sloowwww)
make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs) make server-amd64 - Build server/client binary (amd64, no web app or docs)
make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64) make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
make web - Build the web app make web - Build the web app
make docs - Build the documentation make docs - Build the documentation
make check - Run all tests, vetting/formatting checks and linters make check - Run all tests, vetting/formatting checks and linters
... ...
``` ```
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64), 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`: you can simply run `make build`:
``` shell ``` shell
@@ -158,52 +158,47 @@ $ make release-snapshot
During development, you may want to be more picky and build only certain things. Here are a few examples. During development, you may want to be more picky and build only certain things. Here are a few examples.
### Build the ntfy binary ### Build the ntfy binary
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets: To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
``` shell ``` shell
$ make $ make
Build server & client (using GoReleaser, not release version): Build server & client (not release version):
make cli - Build server & client (all architectures) make server - Build server & client (all architectures)
make cli-linux-amd64 - Build server & client (Linux, amd64 only) make server-amd64 - Build server & client (amd64 only)
make cli-linux-armv6 - Build server & client (Linux, armv6 only) make server-armv7 - Build server & client (armv7 only)
make cli-linux-armv7 - Build server & client (Linux, armv7 only) make server-arm64 - Build server & client (arm64 only)
make cli-linux-arm64 - Build server & client (Linux, arm64 only)
make cli-windows-amd64 - Build client (Windows, amd64 only)
make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)
``` ```
So if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern 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-linux-amd64` so I can run the binary 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: right away:
``` shell ``` shell
$ make cli-linux-amd64 install-linux-amd64 $ make server-amd64 install-amd64
$ ntfy serve $ ntfy serve
``` ```
**During development of the main app, you can also just use `go run main.go`**, as long as you run **During development of the main app, you can also just use `go run main.go`**, as long as you run
`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`: `make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
``` shell ``` shell
$ export CGO_ENABLED=1 $ export CGO_ENABLED=1
$ make cli-deps-static-sites $ make server-deps-static-sites
$ go run main.go serve $ go run main.go serve
2022/03/18 08:43:55 Listening on :2586[http] 2022/03/18 08:43:55 Listening on :2586[http]
... ...
``` ```
If you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*: If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
``` ```
$ go run main.go serve $ go run main.go serve
server/server.go:85:13: pattern docs: no matching files found 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 This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites` 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 ensure that you'll be able to build. target creates dummy files that ensures that you'll be able to build.
While not officially supported (or released), you can build and run the server **on macOS** as well. Simply run
`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it.
### Build the web app ### 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 The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
@@ -215,7 +210,7 @@ $ make web
``` ```
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
that when you `make cli` (or `make cli-linux-amd64`, ...), you will have the web app included in the `ntfy` binary. 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 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 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
@@ -287,13 +282,9 @@ Then either follow the steps for building with or without Firebase.
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will 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. work without issues. Please give me feedback if it does/doesn't work for you.
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) 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:
``` ```
# Remove Google dependencies (FCM)
sed -i -e '/google-services/d' build.gradle
sed -i -e '/google-services/d' app/build.gradle
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk) # To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
./gradlew assembleFdroidRelease ./gradlew assembleFdroidRelease
@@ -310,7 +301,7 @@ 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 [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) * And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
* Then run: * Then run:
``` ```
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk) # To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
@@ -319,9 +310,3 @@ To build your own version with Firebase, you must:
# To build a bundle .aab (app/play/release/*.aab) # To build a bundle .aab (app/play/release/*.aab)
./gradlew bundlePlayRelease ./gradlew bundlePlayRelease
``` ```
## iOS app
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
!!! info
I haven't had time to move the build instructions here. Please check out the repository instead.

View File

@@ -4,11 +4,6 @@ There are a million ways to use ntfy, but here are some inspirations. I try to c
<a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check
those out, too. those out, too.
!!! info
Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please
[create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that
I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.
## A long process is done: backups, copying data, pipelines, ... ## A long process is done: backups, copying data, pipelines, ...
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i> directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
@@ -44,7 +39,7 @@ 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>. web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
## Notify on SSH login ## Notify on SSH login
Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
@@ -103,8 +98,7 @@ One of my co-workers uses the following Ansible task to let him know when things
``` ```
## Watchtower notifications (shoutrrr) ## Watchtower notifications (shoutrrr)
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
Example docker-compose.yml: Example docker-compose.yml:
```yml ```yml
@@ -128,6 +122,7 @@ GitHub have been hopeless. In case it ever becomes available, I want to know imm
``` cron ``` cron
# Check github/ntfy user # 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 */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) ## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
@@ -137,10 +132,11 @@ Some simple bash scripts to achieve this are kindly provided in [nickexyz's repo
## Node-RED ## Node-RED
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples: You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
<details> <details>
<summary>Example: Send a message (click to expand)</summary> <summary>Example: Send a message (click to expand)</summary>
``` json ```
[ [
{ {
"id": "c956e688cc74ad8e", "id": "c956e688cc74ad8e",
@@ -229,7 +225,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
<details> <details>
<summary>Example: Send a picture (click to expand)</summary> <summary>Example: Send a picture (click to expand)</summary>
``` json ```
[ [
{ {
"id": "d135a13eadeb9d6d", "id": "d135a13eadeb9d6d",
@@ -345,8 +341,8 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
## Gatus service health check ## Gatus service health check
An example for a custom alert with [Gatus](https://github.com/TwiN/gatus): An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
``` yaml ```
alerting: alerting:
custom: custom:
url: "https://ntfy.sh" url: "https://ntfy.sh"
@@ -370,66 +366,3 @@ alerting:
TRIGGERED: "warning" TRIGGERED: "warning"
RESOLVED: "white_check_mark" RESOLVED: "white_check_mark"
``` ```
## Jellyseerr/Overseerr webhook
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
``` json
{
"topic": "requests",
"title": "{{event}}",
"message": "{{subject}}\n{{message}}\n\nRequested by: {{requestedBy_username}}\n\nStatus: {{media_status}}\nRequest Id: {{request_id}}",
"priority": 4,
"attach": "{{image}}",
"click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}"
}
```
## Home Assistant
Here is an example for the configuration.yml file to setup a REST notify component.
Since Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource.
```yaml
notify:
- name: ntfy
platform: rest
method: POST_JSON
data:
topic: YOUR_NTFY_TOPIC
title_param_name: title
message_param_name: message
resource: https://ntfy.sh
```
If you need to authenticate to your ntfy resource, define the authentication, username and password as below:
```yaml
notify:
- name: ntfy
platform: rest
method: POST_JSON
authentication: basic
username: YOUR_USERNAME
password: YOUR_PASSWORD
data:
topic: YOUR_NTFY_TOPIC
title_param_name: title
message_param_name: message
resource: https://ntfy.sh
```
If you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example:
```yaml
notify:
- name: ntfy
platform: rest
method: POST_JSON
data:
topic: YOUR_NTFY_TOPIC
priority: 4
title_param_name: title
message_param_name: message
resource: https://ntfy.sh
```

View File

@@ -5,7 +5,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
## Step 1: Get the app ## Step 1: Get the app
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a> <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a> <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a> <a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid. To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid.
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just
@@ -83,7 +83,7 @@ This will create a notification that looks like this:
</figure> </figure>
That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on
[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md). [publishing messages](publish.md), as well as the detailed page on the [Android app](subscribe/phone.md).
Here's another video showing the entire process: Here's another video showing the entire process:

View File

@@ -13,50 +13,50 @@ The ntfy server comes as a statically linked binary and is shipped as tarball, d
We support amd64, armv7 and arm64. We support amd64, armv7 and arm64.
1. Install ntfy using one of the methods described below 1. Install ntfy using one of the methods described below
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)) 2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml)) 3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm). To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md] To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md]
for details). for details).
## Linux binaries ## Binaries and packages
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
deb/rpm packages. deb/rpm packages.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_x86_64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_x86_64.tar.gz
tar zxvf ntfy_1.25.0_linux_x86_64.tar.gz tar zxvf ntfy_1.21.2_linux_x86_64.tar.gz
sudo cp -a ntfy_1.25.0_linux_x86_64/ntfy /usr/bin/ntfy sudo cp -a ntfy_1.21.2_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_x86_64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.tar.gz
tar zxvf ntfy_1.25.0_linux_armv6.tar.gz tar zxvf ntfy_1.21.2_linux_armv6.tar.gz
sudo cp -a ntfy_1.25.0_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_1.21.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.tar.gz
tar zxvf ntfy_1.25.0_linux_armv7.tar.gz tar zxvf ntfy_1.21.2_linux_armv7.tar.gz
sudo cp -a ntfy_1.25.0_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_1.21.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.tar.gz
tar zxvf ntfy_1.25.0_linux_arm64.tar.gz tar zxvf ntfy_1.21.2_linux_arm64.tar.gz
sudo cp -a ntfy_1.25.0_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_1.21.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
@@ -103,7 +103,7 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_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
@@ -111,7 +111,7 @@ Manually installing the .deb file:
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -119,7 +119,7 @@ Manually installing the .deb file:
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_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
@@ -127,7 +127,7 @@ Manually installing the .deb file:
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_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
@@ -137,28 +137,28 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.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.25.0/ntfy_1.25.0_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_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.25.0/ntfy_1.25.0_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@@ -176,45 +176,6 @@ cd ntfysh-bin
makepkg -si makepkg -si
``` ```
## NixOS / Nix
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
```
nix-env -iA ntfy-sh
```
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_v1.25.0_macOS_all.tar.gz > ntfy_v1.25.0_macOS_all.tar.gz
tar zxvf ntfy_v1.25.0_macOS_all.tar.gz
sudo cp -a ntfy_v1.25.0_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_v1.25.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
!!! info
If there is a desire to install ntfy via [Homebrew](https://brew.sh/), please create a
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. Also, you can build and run the
ntfy server on macOS as well, though I don't officially support that. Check out the [build instructions](develop.md)
for details.
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_v1.25.0_windows_x86_64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
!!! info
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
## Docker ## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
be pretty straight forward to use. be pretty straight forward to use.
@@ -239,18 +200,17 @@ docker run \
serve serve
``` ```
With other config options and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details): With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
```bash ```bash
docker run \ docker run \
-v /etc/ntfy:/etc/ntfy \ -v /etc/ntfy:/etc/ntfy \
-p 80:80 \ -p 80:80 \
-u UID:GID \
-it \ -it \
binwiederhier/ntfy \ binwiederhier/ntfy \
serve serve
``` ```
Using docker-compose with non-root user: Using docker-compose:
```yaml ```yaml
version: "2.1" version: "2.1"
@@ -260,7 +220,6 @@ services:
container_name: ntfy container_name: ntfy
command: command:
- serve - serve
user: UID:GID # optional. replace with your own user/group or uid/gid
volumes: volumes:
- /var/cache/ntfy:/var/cache/ntfy - /var/cache/ntfy:/var/cache/ntfy
- /etc/ntfy:/etc/ntfy - /etc/ntfy:/etc/ntfy
@@ -269,8 +228,6 @@ services:
restart: unless-stopped restart: unless-stopped
``` ```
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately. 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

View File

@@ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version. [FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
or messages, though typically this is turned off. aside from a short on-disk cache to support service restarts.

View File

@@ -38,7 +38,7 @@ Here's an example showing how to publish a simple message using a POST request:
=== "PowerShell" === "PowerShell"
``` powershell ``` powershell
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/mytopic -Body "Backup successful" -UseBasicParsing Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing
``` ```
=== "Python" === "Python"
@@ -296,8 +296,6 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
</figure> </figure>
## Message title ## Message title
_Supported on:_ :material-android: :material-apple: :material-firefox:
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title, The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`). you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
@@ -374,9 +372,7 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
</figure> </figure>
## Message priority ## Message priority
_Supported on:_ :material-android: :material-apple: :material-firefox: All messages have a priority, which defines how urgently your phone notifies you. You can set custom
All messages have a priority, which defines how urgently your phone notifies you. On Android, you can set custom
notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)). notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)).
The following priorities exist: The following priorities exist:
@@ -464,8 +460,6 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
</figure> </figure>
## Tags & emojis 🥳 🎉 ## Tags & emojis 🥳 🎉
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can tag messages with emojis and other relevant strings: You can tag messages with emojis and other relevant strings:
* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended * **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended
@@ -585,8 +579,6 @@ them with a comma, e.g. `tag1,tag2,tag3`.
</figure> </figure>
## Scheduled delivery ## Scheduled delivery
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
reminders or even to execute commands at a later date (if your subscriber acts on messages). reminders or even to execute commands at a later date (if your subscriber acts on messages).
@@ -687,8 +679,6 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
</tr></table> </tr></table>
## Webhooks (publish via GET) ## Webhooks (publish via GET)
_Supported on:_ :material-android: :material-apple: :material-firefox:
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).
@@ -792,8 +782,6 @@ Here's an example with a custom message, tags and a priority:
``` ```
## Publish as JSON ## Publish as JSON
_Supported on:_ :material-android: :material-apple: :material-firefox:
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)), 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 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. as JSON in the request body.
@@ -955,8 +943,6 @@ all the supported fields:
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | | `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
## Action buttons ## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly
useful and has countless applications. useful and has countless applications.
@@ -967,7 +953,7 @@ As of today, the following actions are supported:
* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped * [`view`](#open-websiteapp): Opens a website or app when the action button is tapped
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent * [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
when the action button is tapped (only supported on Android) when the action button is tapped
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped * [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
Here's an example of what that a notification with actions can look like: Here's an example of what that a notification with actions can look like:
@@ -1290,8 +1276,6 @@ The required/optional fields for each action depend on the type of the action it
for details. for details.
### Open website/app ### Open website/app
_Supported on:_ :material-android: :material-apple: :material-firefox:
The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or
even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your
desktop browser treat the links. Normally it'll just open a link in the browser. desktop browser treat the links. Normally it'll just open a link in the browser.
@@ -1531,8 +1515,6 @@ The `view` action supports the following fields:
| `clear` | - | *boolean* | `false` | `true` | Clear notification after action button is tapped | | `clear` | - | *boolean* | `false` | `true` | Clear notification after action button is tapped |
### Send Android broadcast ### Send Android broadcast
_Supported on:_ :material-android:
The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
@@ -1797,8 +1779,6 @@ The `broadcast` action supports the following fields:
| `clear` | - | *boolean* | `false` | `true` | Clear notification after action button is tapped | | `clear` | - | *boolean* | `false` | `true` | Clear notification after action button is tapped |
### Send HTTP request ### Send HTTP request
_Supported on:_ :material-android: :material-apple: :material-firefox:
The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs
for whatever systems you have, e.g. opening the garage door, or turning on/off lights. for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
@@ -1811,14 +1791,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
``` ```
curl \ curl \
-d "Garage door has been open for 15 minutes. Close it?" \ -d "Garage door has been open for 15 minutes. Close it?" \
-H "Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ -H "Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
ntfy.sh/myhome ntfy.sh/myhome
``` ```
=== "ntfy CLI" === "ntfy CLI"
``` ```
ntfy publish \ ntfy publish \
--actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ --actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
myhome \ myhome \
"Garage door has been open for 15 minutes. Close it?" "Garage door has been open for 15 minutes. Close it?"
``` ```
@@ -1827,7 +1807,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
``` http ``` http
POST /myhome HTTP/1.1 POST /myhome HTTP/1.1
Host: ntfy.sh Host: ntfy.sh
Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"} Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"}
Garage door has been open for 15 minutes. Close it? Garage door has been open for 15 minutes. Close it?
``` ```
@@ -1838,7 +1818,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
method: 'POST', method: 'POST',
body: 'Garage door has been open for 15 minutes. Close it?', body: 'Garage door has been open for 15 minutes. Close it?',
headers: { headers: {
'Actions': 'http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}' 'Actions': 'http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}'
} }
}) })
``` ```
@@ -1846,14 +1826,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
=== "Go" === "Go"
``` go ``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?")) req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?"))
req.Header.Set("Actions", "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}") req.Header.Set("Actions", "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}")
http.DefaultClient.Do(req) http.DefaultClient.Do(req)
``` ```
=== "PowerShell" === "PowerShell"
``` powershell ``` powershell
$uri = "https://ntfy.sh/myhome" $uri = "https://ntfy.sh/myhome"
$headers = @{ Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" } $headers = @{ Actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }
$body = "Garage door has been open for 15 minutes. Close it?" $body = "Garage door has been open for 15 minutes. Close it?"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
``` ```
@@ -1862,7 +1842,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
``` python ``` python
requests.post("https://ntfy.sh/myhome", requests.post("https://ntfy.sh/myhome",
data="Garage door has been open for 15 minutes. Close it?", data="Garage door has been open for 15 minutes. Close it?",
headers={ "Actions": "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }) headers={ "Actions": "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" })
``` ```
=== "PHP" === "PHP"
@@ -1872,7 +1852,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
'method' => 'POST', 'method' => 'POST',
'header' => 'header' =>
"Content-Type: text/plain\r\n" . "Content-Type: text/plain\r\n" .
"Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}", "Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}",
'content' => 'Garage door has been open for 15 minutes. Close it?' 'content' => 'Garage door has been open for 15 minutes. Close it?'
] ]
])); ]));
@@ -2075,8 +2055,6 @@ The `http` action supports the following fields:
| `clear` | - | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. | | `clear` | - | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
## Click action ## Click action
_Supported on:_ :material-android: :material-apple: :material-firefox:
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
the web browser (or the app) and open the website. the web browser (or the app) and open the website.
@@ -2165,8 +2143,6 @@ Here's an example that will open Reddit when the notification is clicked:
``` ```
## Attachments ## Attachments
_Supported on:_ :material-android: :material-firefox:
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
@@ -2339,8 +2315,6 @@ Here's an example showing how to attach an APK file:
</figure> </figure>
## E-mail notifications ## E-mail notifications
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
you'd like to persist longer, or to blast-notify yourself on all possible channels. you'd like to persist longer, or to blast-notify yourself on all possible channels.
@@ -2451,8 +2425,6 @@ Here's what that looks like in Google Mail:
</figure> </figure>
## E-mail publishing ## E-mail publishing
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls). based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
@@ -2532,11 +2504,9 @@ Here's a simple example:
=== "PowerShell" === "PowerShell"
``` powershell ``` powershell
$uri = "https://ntfy.example.com/mysecrets" $uri = "https://ntfy.example.com/mysecrets"
$credentials = 'username:password' $headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" }
$encodedCredentials = [convert]::ToBase64String([text.Encoding]::UTF8.GetBytes($credentials)) $body = "Look ma, with auth"
$headers = @{Authorization="Basic $encodedCredentials"} Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
``` ```
=== "Python" === "Python"
@@ -2782,5 +2752,4 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | | `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) |
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics | | `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |

View File

@@ -4,148 +4,11 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
<!-- <!--
## ntfy Android app v1.14.0 (UNRELEASED) ## ntfy Android app v1.13.0 (UNRELEASED)
**Additional translations:**
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
-->
## ntfy server v1.25.0
Released June 2, 2022
**Features:** **Features:**
* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284)) * Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@cmeis](https://github.com/cmeis) for reporting)
**Bugs**:
* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
**Maintenance:**
* Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274))
* CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36))
**Documentation**:
* ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket)
* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))
* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)
* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl))
**Additional translations:**
* Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/))
* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
## ntfy iOS app v1.1
Released May 31, 2022
In this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis,
action buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click
action when the notification is tapped, and various other fixes.
It also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be
configured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications)
for details).
**Features:**
* [Message priority](https://ntfy.sh/docs/publish/#message-priority) support (no ticket)
* [Tags/emojis](https://ntfy.sh/docs/publish/#tags-emojis) support (no ticket)
* [Action buttons](https://ntfy.sh/docs/publish/#action-buttons) support (no ticket)
* [Click action](https://ntfy.sh/docs/publish/#click-action) support (no ticket)
* Open topic when notification clicked (no ticket)
* Notification now makes a sound and vibrates (no ticket)
* Cancel notifications when navigating to topic (no ticket)
* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99))
**Bugs:**
* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))
## ntfy server v1.24.0
Released May 28, 2022
This release of the ntfy server brings supporting features for the ntfy iOS app. Most importantly, it
enables support for self-hosted servers in combination with the iOS app. This is to overcome the restrictive
Apple development environment.
**Features:**
* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket)
* Add subscribe filter to query exact messages by ID (no ticket)
* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket)
**Bugs:**
* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))
**Additional translations:**
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
## ntfy iOS app v1.0
Released May 25, 2022
This is the first version of the ntfy iOS app. It supports only ntfy.sh (no selfhosted servers) and only messages + title
(no priority, tags, attachments, ...). I'll rapidly add (hopefully) most of the other ntfy features, and then I'll focus
on self-hosted servers.
The app is now available in the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
**Tickets:**
* iOS app ([#4](https://github.com/binwiederhier/ntfy/issues/4), see also: [TestFlight summary](https://github.com/binwiederhier/ntfy/issues/4#issuecomment-1133767150))
**Thanks:**
* Thank you to all the testers who tried out the app. You guys gave me the confidence that it's ready to release (albeit with
some known issues which will be addressed in follow-up releases).
## ntfy server v1.23.0
Released May 21, 2022
This release ships a CLI for Windows and macOS, as well as the ability to disable the web app entirely. On top of that,
it adds support for APNs, the iOS messaging service. This is needed for the (soon to be released) iOS app.
**Features:**
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))
* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia))
**Bugs:**
* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado))
* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama))
**Documentation:**
* Typo in install instructions ([#252](https://github.com/binwiederhier/ntfy/pull/252)/[#251](https://github.com/binwiederhier/ntfy/issues/251), thanks to [@oddlama](https://github.com/oddlama))
* Fix typo in private server example ([#262](https://github.com/binwiederhier/ntfy/pull/262), thanks to [@MayeulC](https://github.com/MayeulC))
* [Examples](examples.md) for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) ([#264](https://github.com/binwiederhier/ntfy/pull/264), thanks to [@Fallenbagel](https://github.com/Fallenbagel))
**Additional translations:**
* Portuguese/Brazil (thanks to [@tiagotriques](https://hosted.weblate.org/user/tiagotriques/) and [@pireshenrique22](https://hosted.weblate.org/user/pireshenrique22/))
Thank you to the many translators, who helped translate the new strings so quickly. I am humbled and amazed by your help.
## ntfy Android app v1.13.0
Released May 11, 2022
This release brings a slightly altered design for the detail view, featuring a card layout to make notifications more easily
distinguishable from one another. It also ships per-topic settings that allow overriding minimum priority, auto delete threshold
and custom icons. Aside from that, we've got tons of bug fixes as usual.
**Features:**
* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting)
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting)
**Bugs:** **Bugs:**
@@ -155,53 +18,41 @@ and custom icons. Aside from that, we've got tons of bug fixes as usual.
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting) * Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting) * Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233)) * Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
* Prevent long topic names and icons from overlapping ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting)
**Additional translations:** **Thanks for testing:**
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/)) Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
to [@Joeharrison94](https://github.com/Joeharrison94) for the input.
**Thank you:** ## ntfy server v1.22.0 (UNRELEASED)
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
to [@Joeharrison94](https://github.com/Joeharrison94) for the input. And thank you very much to all the translators for catching up so quickly.
## ntfy server v1.22.0
Released May 7, 2022
This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app.
It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.
We've also improved the documentation a little and added translations for three more languages.
**Features:** **Features:**
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
* Better parsing of the user actions, allowing quotes (no ticket) * Better parsing of the user actions, allowing quotes (no ticket)
* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8)) * Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
**Bugs:** **Bugs:**
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it) * `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222)) * Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting) * Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
**Documentation:** **Documentation:**
* Improved caddy configuration (no ticket, thanks to @Stnby) * Improved caddy configuration (no ticket, thanks to @Stnby)
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable)) * Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW))
**Additional translations:** **Additional translations:**
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/)) * Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/)) * French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/))
**Thanks for testing:** **Thanks for testing:**
Thanks to [@wunter8](https://github.com/wunter8) for testing. Thanks to [@wunter8](https://github.com/wunter8) for testing.
-->
## ntfy Android app v1.12.0 ## ntfy Android app v1.12.0
Released Apr 25, 2022 Released Apr 25, 2022

View File

@@ -8,8 +8,8 @@
width: unset !important; width: unset !important;
} }
.md-header__topic:first-child { .md-sidebar {
font-weight: 400; width: 12.5rem !important;
} }
.md-typeset h4 { .md-typeset h4 {

View File

@@ -267,7 +267,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
``` ```
### Filter messages ### Filter messages
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags `tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND. "zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
@@ -280,13 +280,12 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
Available filters (all case-insensitive): Available filters (all case-insensitive):
| Filter variable | Alias | Example | Description | | Filter variable | Alias | Example | Description |
|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------| |-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID | | `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string | | `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string | | `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | | `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
### Subscribe to multiple topics ### Subscribe to multiple topics
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
@@ -316,19 +315,18 @@ format of the message. It's very straight forward:
**Message**: **Message**:
| Field | Required | Type | Example | Description | | Field | Required | Type | Example | Description |
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| |--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | | `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | | `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | | `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | | `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events | | `message` | - | *string* | `Some message` | Message body; always present in `message` events |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` | | `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | | `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | | `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | | `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
@@ -418,7 +416,6 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| `poll` | `X-Poll`, `po` | Return cached messages and close connection | | `poll` | `X-Poll`, `po` | Return cached messages and close connection |
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID | | `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list | | `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string | | `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string | | `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) | | `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |

View File

@@ -123,7 +123,7 @@ which will read the `subscribe` config from the config file. Please also check o
Here's an example config file that subscribes to three different topics, executing a different command for each of them: Here's an example config file that subscribes to three different topics, executing a different command for each of them:
=== "~/.config/ntfy/client.yml (Linux)" === "~/.config/ntfy/client.yml"
```yaml ```yaml
subscribe: subscribe:
- topic: echo-this - topic: echo-this
@@ -145,42 +145,12 @@ Here's an example config file that subscribes to three different topics, executi
fi fi
``` ```
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
```yaml
subscribe:
- topic: echo-this
command: 'echo "Message received: $message"'
- topic: alerts
command: osascript -e "display notification \"$message\""
if:
priority: high,urgent
- topic: calc
command: open -a Calculator
```
=== "%AppData%\ntfy\client.yml (Windows)"
```yaml
subscribe:
- topic: echo-this
command: 'echo Message received: %message%'
- topic: alerts
command: |
notifu /m "%NTFY_MESSAGE%"
exit 0
if:
priority: high,urgent
- topic: calc
command: calc
```
In this example, when `ntfy subscribe --from-config` is executed: In this example, when `ntfy subscribe --from-config` is executed:
* Messages to `echo-this` simply echos to standard out * Messages to `echo-this` simply echos to standard out
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux), * Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html)
[notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS) * Messages to `calc` open the gnome calculator 😀 (*because, why not*)
* Messages to `calc` open the calculator 😀 (*because, why not*) * Messages to `print-temp` execute an inline script and print the CPU temperature
* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only)
I hope this shows how powerful this command is. Here's a short video that demonstrates the above example: I hope this shows how powerful this command is. Here's a short video that demonstrates the above example:

View File

@@ -1,16 +1,14 @@
# Subscribe from your phone # Subscribe from your phone
You can use the ntfy [Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [iOS app](https://apps.apple.com/us/app/ntfy/id1625396347) You can use the [ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive
to receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4).
contribute, or [build your own](../develop.md).
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a> <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a> <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). the F-Droid flavor does not use Firebase.
## Overview ## Overview
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
@@ -33,8 +31,6 @@ If those screenshots are still not enough, here's a video:
</figure> </figure>
## Message priority ## Message priority
_Supported on:_ :material-android: :material-apple:
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate. how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
@@ -63,8 +59,6 @@ setting, and other settings such as popover or notification dot:
</figure> </figure>
## Instant delivery ## Instant delivery
_Supported on:_ :material-android:
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e. Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
you'll see as a permanent notification that looks like this: you'll see as a permanent notification that looks like this:
@@ -95,8 +89,6 @@ The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor. It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
## Share to topic ## Share to topic
_Supported on:_ :material-android:
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
you shared content to and lists them at the bottom. you shared content to and lists them at the bottom.
@@ -109,8 +101,6 @@ The feature is pretty self-explanatory, and one picture says more than a thousan
</div> </div>
## ntfy:// links ## ntfy:// links
_Supported on:_ :material-android:
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps) The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
or to simply directly link to a topic from a mobile website. or to simply directly link to a topic from a mobile website.
@@ -129,8 +119,6 @@ or to simply directly link to a topic from a mobile website.
## Integrations ## Integrations
### UnifiedPush ### UnifiedPush
_Supported on:_ :material-android:
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned [UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it. in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
@@ -146,8 +134,6 @@ to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
</div> </div>
### Automation apps ### Automation apps
_Supported on:_ :material-android:
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
**react to incoming messages**, as well as **send messages**. **react to incoming messages**, as well as **send messages**.
@@ -224,3 +210,9 @@ The following intent extras are supported when for the intent with the `io.hecke
| `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
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
for ntfy, but it's in the works. You can track the status on GitHub.
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>

40
go.mod
View File

@@ -4,31 +4,30 @@ 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.22.1 // indirect cloud.google.com/go/storage v1.22.0 // indirect
firebase.google.com/go v3.13.0+incompatible
github.com/BurntSushi/toml v1.1.0 // indirect github.com/BurntSushi/toml v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // 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.5.0 github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.13 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.8.1 github.com/urfave/cli/v2 v2.4.7
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
golang.org/x/time v0.0.0-20220411224347-583f2d630306 golang.org/x/time v0.0.0-20220411224347-583f2d630306
google.golang.org/api v0.82.0 google.golang.org/api v0.75.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require github.com/pkg/errors v0.9.1 // indirect require github.com/pkg/errors v0.9.1 // indirect
require firebase.google.com/go/v4 v4.8.0
require ( require (
cloud.google.com/go v0.102.0 // indirect cloud.google.com/go v0.101.0 // indirect
cloud.google.com/go/compute v1.6.1 // indirect cloud.google.com/go/compute v1.6.1 // indirect
cloud.google.com/go/iam v0.3.0 // indirect cloud.google.com/go/iam v0.3.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect
@@ -36,22 +35,19 @@ require (
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.8 // indirect github.com/google/go-cmp v0.5.7 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/googleapis/gax-go/v2 v2.3.0 // indirect
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
github.com/googleapis/go-type-adapters v1.0.0 // indirect github.com/googleapis/go-type-adapters v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.23.0 // indirect go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.1 // indirect google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect google.golang.org/grpc v1.46.0 // indirect
google.golang.org/grpc v1.47.0 // indirect
google.golang.org/protobuf v1.28.0 // 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
) )

103
go.sum
View File

@@ -26,10 +26,9 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8= cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -37,7 +36,6 @@ 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/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
@@ -47,7 +45,6 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7
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/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= 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/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=
@@ -59,12 +56,11 @@ 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.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= cloud.google.com/go/storage v1.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg= cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go/v4 v4.8.0 h1:ooJqjFEh1G6DQ5+wyb/RAXAgku0E2RzJeH6WauSpWSo= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go/v4 v4.8.0/go.mod h1:y+j6xX7BgBco/XaN+YExIBVm6pzvYutheDV3nprvbWc= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
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 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
@@ -90,9 +86,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/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/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -165,15 +160,15 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -190,16 +185,13 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.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/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk=
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA= github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -217,8 +209,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/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.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I= github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
github.com/mattn/go-sqlite3 v1.14.13/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=
@@ -239,10 +231,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4= github.com/urfave/cli/v2 v2.4.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo=
github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY= github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.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=
@@ -262,8 +252,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
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/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -340,10 +330,8 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8=
golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-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=
@@ -363,9 +351,8 @@ golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ
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-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw=
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-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=
@@ -376,10 +363,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -430,21 +415,17 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-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-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-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-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -515,9 +496,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -550,19 +530,12 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0/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.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
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.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw=
google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.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=
@@ -571,8 +544,6 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine/v2 v2.0.1 h1:jTGfiRmR5qoInpT3CXJ72GJEB4owDGEKN+xRDA6ekBY=
google.golang.org/appengine/v2 v2.0.1/go.mod h1:XgltgQxPOF3ShivrVrZyfvYCx8Dunh73bKjUuXUZb8Q=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -636,30 +607,20 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-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-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-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-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-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-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-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-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.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=
@@ -688,10 +649,8 @@ google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K
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/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/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/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=

View File

@@ -1,129 +0,0 @@
package log
import (
"log"
"strings"
"sync"
)
// Level is a well-known log level, as defined below
type Level int
// Well known log levels
const (
TraceLevel Level = iota
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
)
func (l Level) String() string {
switch l {
case TraceLevel:
return "TRACE"
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case WarnLevel:
return "WARN"
case ErrorLevel:
return "ERROR"
}
return "unknown"
}
var (
level = InfoLevel
mu = &sync.Mutex{}
)
// Trace prints the given message, if the current log level is TRACE
func Trace(message string, v ...interface{}) {
logIf(TraceLevel, message, v...)
}
// Debug prints the given message, if the current log level is DEBUG or lower
func Debug(message string, v ...interface{}) {
logIf(DebugLevel, message, v...)
}
// Info prints the given message, if the current log level is INFO or lower
func Info(message string, v ...interface{}) {
logIf(InfoLevel, message, v...)
}
// Warn prints the given message, if the current log level is WARN or lower
func Warn(message string, v ...interface{}) {
logIf(WarnLevel, message, v...)
}
// Error prints the given message, if the current log level is ERROR or lower
func Error(message string, v ...interface{}) {
logIf(ErrorLevel, message, v...)
}
// Fatal prints the given message, and exits the program
func Fatal(v ...interface{}) {
log.Fatalln(v...)
}
// CurrentLevel returns the current log level
func CurrentLevel() Level {
mu.Lock()
defer mu.Unlock()
return level
}
// SetLevel sets a new log level
func SetLevel(newLevel Level) {
mu.Lock()
defer mu.Unlock()
level = newLevel
}
// DisableDates disables the date/time prefix
func DisableDates() {
log.SetFlags(0)
}
// ToLevel converts a string to a Level. It returns InfoLevel if the string
// does not match any known log levels.
func ToLevel(s string) Level {
switch strings.ToUpper(s) {
case "TRACE":
return TraceLevel
case "DEBUG":
return DebugLevel
case "INFO":
return InfoLevel
case "WARN", "WARNING":
return WarnLevel
case "ERROR":
return ErrorLevel
default:
return InfoLevel
}
}
// Loggable returns true if the given log level is lower or equal to the current log level
func Loggable(l Level) bool {
return CurrentLevel() <= l
}
// IsTrace returns true if the current log level is TraceLevel
func IsTrace() bool {
return Loggable(TraceLevel)
}
// IsDebug returns true if the current log level is DebugLevel or below
func IsDebug() bool {
return Loggable(DebugLevel)
}
func logIf(l Level, message string, v ...interface{}) {
if CurrentLevel() <= l {
log.Printf(l.String()+" "+message, v...)
}
}

View File

@@ -61,9 +61,6 @@ markdown_extensions:
custom_checkbox: true custom_checkbox: true
- attr_list - attr_list
- md_in_html - md_in_html
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
plugins: plugins:
- search - search

View File

@@ -6,16 +6,14 @@ import (
// Defines default config settings (excluding limits, see below) // Defines default config settings (excluding limits, see below)
const ( const (
DefaultListenHTTP = ":80" DefaultListenHTTP = ":80"
DefaultCacheDuration = 12 * time.Hour DefaultCacheDuration = 12 * time.Hour
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
DefaultManagerInterval = time.Minute DefaultManagerInterval = time.Minute
DefaultDelayedSenderInterval = 10 * time.Second DefaultAtSenderInterval = 10 * time.Second
DefaultMinDelay = 10 * time.Second DefaultMinDelay = 10 * time.Second
DefaultMaxDelay = 3 * 24 * time.Hour DefaultMaxDelay = 3 * 24 * time.Hour
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
) )
// Defines all global and per-visitor limits // Defines all global and per-visitor limits
@@ -67,11 +65,8 @@ type Config struct {
KeepaliveInterval time.Duration KeepaliveInterval time.Duration
ManagerInterval time.Duration ManagerInterval time.Duration
WebRootIsApp bool WebRootIsApp bool
DelayedSenderInterval time.Duration AtSenderInterval time.Duration
FirebaseKeepaliveInterval time.Duration FirebaseKeepaliveInterval time.Duration
FirebasePollInterval time.Duration
FirebaseQuotaExceededPenaltyDuration time.Duration
UpstreamBaseURL string
SMTPSenderAddr string SMTPSenderAddr string
SMTPSenderUser string SMTPSenderUser string
SMTPSenderPass string SMTPSenderPass string
@@ -93,7 +88,6 @@ type Config struct {
VisitorEmailLimitBurst int VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration VisitorEmailLimitReplenish time.Duration
BehindProxy bool BehindProxy bool
EnableWeb bool
} }
// NewConfig instantiates a default new server config // NewConfig instantiates a default new server config
@@ -120,10 +114,8 @@ func NewConfig() *Config {
MessageLimit: DefaultMessageLengthLimit, MessageLimit: DefaultMessageLengthLimit,
MinDelay: DefaultMinDelay, MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay, MaxDelay: DefaultMaxDelay,
DelayedSenderInterval: DefaultDelayedSenderInterval, AtSenderInterval: DefaultAtSenderInterval,
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
FirebasePollInterval: DefaultFirebasePollInterval,
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
TotalTopicLimit: DefaultTotalTopicLimit, TotalTopicLimit: DefaultTotalTopicLimit,
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
@@ -134,6 +126,5 @@ func NewConfig() *Config {
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
BehindProxy: false, BehindProxy: false,
EnableWeb: true,
} }
} }

View File

@@ -6,8 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"log"
"strings" "strings"
"time" "time"
) )
@@ -36,7 +36,7 @@ const (
attachment_size INT NOT NULL, attachment_size INT NOT NULL,
attachment_expires INT NOT NULL, attachment_expires INT NOT NULL,
attachment_url TEXT NOT NULL, attachment_url TEXT NOT NULL,
sender TEXT NOT NULL, attachment_owner TEXT NOT NULL,
encoding TEXT NOT NULL, encoding TEXT NOT NULL,
published INT NOT NULL published INT NOT NULL
); );
@@ -45,37 +45,37 @@ const (
COMMIT; COMMIT;
` `
insertMessageQuery = ` insertMessageQuery = `
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
selectMessagesSinceTimeQuery = ` selectMessagesSinceTimeQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages FROM messages
WHERE topic = ? AND time >= ? AND published = 1 WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceTimeIncludeScheduledQuery = ` selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages FROM messages
WHERE topic = ? AND time >= ? WHERE topic = ? AND time >= ?
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceIDQuery = ` selectMessagesSinceIDQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages FROM messages
WHERE topic = ? AND id > ? AND published = 1 WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceIDIncludeScheduledQuery = ` selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages FROM messages
WHERE topic = ? AND (id > ? OR published = 0) WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id ORDER BY time, id
` `
selectMessagesDueQuery = ` selectMessagesDueQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages FROM messages
WHERE time <= ? AND published = 0 WHERE time <= ? AND published = 0
ORDER BY time, id ORDER BY time, id
@@ -84,13 +84,13 @@ const (
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?` selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
) )
// Schema management queries // Schema management queries
const ( const (
currentSchemaVersion = 7 currentSchemaVersion = 6
createSchemaVersionTableQuery = ` createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion ( CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY, id INT PRIMARY KEY,
@@ -173,11 +173,6 @@ const (
migrate5To6AlterMessagesTableQuery = ` migrate5To6AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
` `
// 6 -> 7
migrate6To7AlterMessagesTableQuery = `
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
`
) )
type messageCache struct { type messageCache struct {
@@ -230,7 +225,7 @@ func (c *messageCache) AddMessage(m *message) error {
} }
published := m.Time <= time.Now().Unix() published := m.Time <= time.Now().Unix()
tags := strings.Join(m.Tags, ",") tags := strings.Join(m.Tags, ",")
var attachmentName, attachmentType, attachmentURL string var attachmentName, attachmentType, attachmentURL, attachmentOwner string
var attachmentSize, attachmentExpires int64 var attachmentSize, attachmentExpires int64
if m.Attachment != nil { if m.Attachment != nil {
attachmentName = m.Attachment.Name attachmentName = m.Attachment.Name
@@ -238,6 +233,7 @@ func (c *messageCache) AddMessage(m *message) error {
attachmentSize = m.Attachment.Size attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires attachmentExpires = m.Attachment.Expires
attachmentURL = m.Attachment.URL attachmentURL = m.Attachment.URL
attachmentOwner = m.Attachment.Owner
} }
var actionsStr string var actionsStr string
if len(m.Actions) > 0 { if len(m.Actions) > 0 {
@@ -263,7 +259,7 @@ func (c *messageCache) AddMessage(m *message) error {
attachmentSize, attachmentSize,
attachmentExpires, attachmentExpires,
attachmentURL, attachmentURL,
m.Sender, attachmentOwner,
m.Encoding, m.Encoding,
published, published,
) )
@@ -375,8 +371,8 @@ func (c *messageCache) Prune(olderThan time.Time) error {
return err return err
} }
func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) { func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeQuery, sender, 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
} }
@@ -419,7 +415,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
for rows.Next() { for rows.Next() {
var timestamp, attachmentSize, attachmentExpires int64 var timestamp, attachmentSize, attachmentExpires int64
var priority int var priority int
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
err := rows.Scan( err := rows.Scan(
&id, &id,
&timestamp, &timestamp,
@@ -435,7 +431,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
&attachmentSize, &attachmentSize,
&attachmentExpires, &attachmentExpires,
&attachmentURL, &attachmentURL,
&sender, &attachmentOwner,
&encoding, &encoding,
) )
if err != nil { if err != nil {
@@ -459,6 +455,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
Size: attachmentSize, Size: attachmentSize,
Expires: attachmentExpires, Expires: attachmentExpires,
URL: attachmentURL, URL: attachmentURL,
Owner: attachmentOwner,
} }
} }
messages = append(messages, &message{ messages = append(messages, &message{
@@ -473,7 +470,6 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
Click: click, Click: click,
Actions: actions, Actions: actions,
Attachment: att, Attachment: att,
Sender: sender,
Encoding: encoding, Encoding: encoding,
}) })
} }
@@ -520,8 +516,6 @@ func setupCacheDB(db *sql.DB) error {
return migrateFrom4(db) return migrateFrom4(db)
} else if schemaVersion == 5 { } else if schemaVersion == 5 {
return migrateFrom5(db) return migrateFrom5(db)
} else if schemaVersion == 6 {
return migrateFrom6(db)
} }
return fmt.Errorf("unexpected schema version found: %d", schemaVersion) return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
} }
@@ -540,7 +534,7 @@ func setupNewCacheDB(db *sql.DB) error {
} }
func migrateFrom0(db *sql.DB) error { func migrateFrom0(db *sql.DB) error {
log.Info("Migrating cache database schema: from 0 to 1") log.Print("Migrating cache database schema: from 0 to 1")
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -554,7 +548,7 @@ func migrateFrom0(db *sql.DB) error {
} }
func migrateFrom1(db *sql.DB) error { func migrateFrom1(db *sql.DB) error {
log.Info("Migrating cache database schema: from 1 to 2") log.Print("Migrating cache database schema: from 1 to 2")
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -565,7 +559,7 @@ func migrateFrom1(db *sql.DB) error {
} }
func migrateFrom2(db *sql.DB) error { func migrateFrom2(db *sql.DB) error {
log.Info("Migrating cache database schema: from 2 to 3") log.Print("Migrating cache database schema: from 2 to 3")
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -576,7 +570,7 @@ func migrateFrom2(db *sql.DB) error {
} }
func migrateFrom3(db *sql.DB) error { func migrateFrom3(db *sql.DB) error {
log.Info("Migrating cache database schema: from 3 to 4") log.Print("Migrating cache database schema: from 3 to 4")
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -587,7 +581,7 @@ func migrateFrom3(db *sql.DB) error {
} }
func migrateFrom4(db *sql.DB) error { func migrateFrom4(db *sql.DB) error {
log.Info("Migrating cache database schema: from 4 to 5") log.Print("Migrating cache database schema: from 4 to 5")
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -598,23 +592,12 @@ func migrateFrom4(db *sql.DB) error {
} }
func migrateFrom5(db *sql.DB) error { func migrateFrom5(db *sql.DB) error {
log.Info("Migrating cache database schema: from 5 to 6") log.Print("Migrating cache database schema: from 5 to 6")
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
return err return err
} }
if _, err := db.Exec(updateSchemaVersion, 6); err != nil { if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
return err return err
} }
return migrateFrom6(db)
}
func migrateFrom6(db *sql.DB) error {
log.Info("Migrating cache database schema: from 6 to 7")
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
return err
}
return nil // Update this when a new version is added return nil // Update this when a new version is added
} }

View File

@@ -281,39 +281,39 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() expires1 := time.Now().Add(-4 * time.Hour).Unix()
m := newDefaultMessage("mytopic", "flower for you") m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1" m.ID = "m1"
m.Sender = "1.2.3.4"
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "flower.jpg", Name: "flower.jpg",
Type: "image/jpeg", Type: "image/jpeg",
Size: 5000, Size: 5000,
Expires: expires1, Expires: expires1,
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
Owner: "1.2.3.4",
} }
require.Nil(t, c.AddMessage(m)) require.Nil(t, c.AddMessage(m))
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car") m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2" m.ID = "m2"
m.Sender = "1.2.3.4"
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "car.jpg", Name: "car.jpg",
Type: "image/jpeg", Type: "image/jpeg",
Size: 10000, Size: 10000,
Expires: expires2, Expires: expires2,
URL: "https://ntfy.sh/file/aCaRURL.jpg", URL: "https://ntfy.sh/file/aCaRURL.jpg",
Owner: "1.2.3.4",
} }
require.Nil(t, c.AddMessage(m)) require.Nil(t, c.AddMessage(m))
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car") m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3" m.ID = "m3"
m.Sender = "1.2.3.4"
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "another-car.jpg", Name: "another-car.jpg",
Type: "image/jpeg", Type: "image/jpeg",
Size: 20000, Size: 20000,
Expires: expires3, Expires: expires3,
URL: "https://ntfy.sh/file/zakaDHFW.jpg", URL: "https://ntfy.sh/file/zakaDHFW.jpg",
Owner: "1.2.3.4",
} }
require.Nil(t, c.AddMessage(m)) require.Nil(t, c.AddMessage(m))
@@ -327,7 +327,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
require.Equal(t, int64(5000), messages[0].Attachment.Size) require.Equal(t, int64(5000), messages[0].Attachment.Size)
require.Equal(t, expires1, messages[0].Attachment.Expires) require.Equal(t, expires1, messages[0].Attachment.Expires)
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[0].Sender) require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
require.Equal(t, "sending you a car", messages[1].Message) require.Equal(t, "sending you a car", messages[1].Message)
require.Equal(t, "car.jpg", messages[1].Attachment.Name) require.Equal(t, "car.jpg", messages[1].Attachment.Name)
@@ -335,7 +335,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
require.Equal(t, int64(10000), messages[1].Attachment.Size) require.Equal(t, int64(10000), messages[1].Attachment.Size)
require.Equal(t, expires2, messages[1].Attachment.Expires) require.Equal(t, expires2, messages[1].Attachment.Expires)
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].Sender) require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
size, err := c.AttachmentBytesUsed("1.2.3.4") size, err := c.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err) require.Nil(t, err)

View File

@@ -5,8 +5,7 @@ After=network.target
[Service] [Service]
User=ntfy User=ntfy
Group=ntfy Group=ntfy
ExecStart=/usr/bin/ntfy serve --no-log-dates ExecStart=/usr/bin/ntfy serve
ExecReload=/bin/kill --signal HUP $MAINPID
Restart=on-failure Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
LimitNOFILE=10000 LimitNOFILE=10000

View File

@@ -3,15 +3,21 @@ package server
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"embed" "embed"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"heckel.io/ntfy/log" "github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"io" "io"
"log"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"os" "os"
"path" "path"
@@ -22,32 +28,26 @@ import (
"sync" "sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
) )
// Server is the main server, providing the UI and API for ntfy // Server is the main server, providing the UI and API for ntfy
type Server struct { type Server struct {
config *Config config *Config
httpServer *http.Server httpServer *http.Server
httpsServer *http.Server httpsServer *http.Server
unixListener net.Listener unixListener net.Listener
smtpServer *smtp.Server smtpServer *smtp.Server
smtpServerBackend *smtpBackend smtpBackend *smtpBackend
smtpSender mailer topics map[string]*topic
topics map[string]*topic visitors map[string]*visitor
visitors map[string]*visitor firebase subscriber
firebaseClient *firebaseClient mailer mailer
messages int64 messages int64
auth auth.Auther auth auth.Auther
messageCache *messageCache messageCache *messageCache
fileCache *fileCache fileCache *fileCache
closeChan chan bool closeChan chan bool
mu sync.Mutex mu sync.Mutex
} }
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors // handleFunc extends the normal http.HandlerFunc to be able to easily return errors
@@ -90,9 +90,7 @@ var (
const ( const (
firebaseControlTopic = "~control" // See Android if changed firebaseControlTopic = "~control" // See Android if changed
firebasePollTopic = "~poll" // See iOS if changed
emptyMessageBody = "triggered" // Used if message body is empty emptyMessageBody = "triggered" // Used if message body is empty
newMessageBody = "New message" // Used in poll requests as generic message
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64" encodingBase64 = "base64"
) )
@@ -134,23 +132,23 @@ func New(conf *Config) (*Server, error) {
return nil, err return nil, err
} }
} }
var firebaseClient *firebaseClient var firebaseSubscriber subscriber
if conf.FirebaseKeyFile != "" { if conf.FirebaseKeyFile != "" {
sender, err := newFirebaseSender(conf.FirebaseKeyFile) var err error
firebaseSubscriber, err = createFirebaseSubscriber(conf.FirebaseKeyFile, auther)
if err != nil { if err != nil {
return nil, err return nil, err
} }
firebaseClient = newFirebaseClient(sender, auther)
} }
return &Server{ return &Server{
config: conf, config: conf,
messageCache: messageCache, messageCache: messageCache,
fileCache: fileCache, fileCache: fileCache,
firebaseClient: firebaseClient, firebase: firebaseSubscriber,
smtpSender: mailer, mailer: mailer,
topics: topics, topics: topics,
auth: auther, auth: auther,
visitors: make(map[string]*visitor), visitors: make(map[string]*visitor),
}, nil }, nil
} }
@@ -179,7 +177,7 @@ func (s *Server) Run() error {
if s.config.SMTPServerListen != "" { if s.config.SMTPServerListen != "" {
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
} }
log.Info("Listening on%s, log level is %s", listenStr, log.CurrentLevel().String()) log.Printf("Listening on%s", listenStr)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", s.handle) mux.HandleFunc("/", s.handle)
errChan := make(chan error) errChan := make(chan error)
@@ -219,7 +217,7 @@ func (s *Server) Run() error {
} }
s.mu.Unlock() s.mu.Unlock()
go s.runManager() go s.runManager()
go s.runDelayedSender() go s.runAtSender()
go s.runFirebaseKeepaliver() go s.runFirebaseKeepaliver()
return <-errChan return <-errChan
@@ -246,27 +244,16 @@ func (s *Server) Stop() {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
v := s.visitor(r) v := s.visitor(r)
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
if err := s.handleInternal(w, r, v); err != nil { if err := s.handleInternal(w, r, v); err != nil {
if websocket.IsWebSocketUpgrade(r) { if websocket.IsWebSocketUpgrade(r) {
isNormalError := strings.Contains(err.Error(), "i/o timeout") log.Printf("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
if isNormalError {
log.Debug("%s WebSocket error (this error is okay, it happens a lot): %s", logHTTPPrefix(v, r), err.Error())
} else {
log.Info("%s WebSocket error: %s", logHTTPPrefix(v, r), err.Error())
}
return // Do not attempt to write to upgraded connection return // Do not attempt to write to upgraded connection
} }
httpErr, ok := err.(*errHTTP) httpErr, ok := err.(*errHTTP)
if !ok { if !ok {
httpErr = errHTTPInternalError httpErr = errHTTPInternalError
} }
isNormalError := httpErr.HTTPCode == http.StatusNotFound log.Printf("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
if isNormalError {
log.Debug("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
} else {
log.Info("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.WriteHeader(httpErr.HTTPCode) w.WriteHeader(httpErr.HTTPCode)
@@ -276,23 +263,23 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
if r.Method == http.MethodGet && r.URL.Path == "/" { if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.ensureWebEnabled(s.handleHome)(w, r, v) return s.handleHome(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
return s.ensureWebEnabled(s.handleExample)(w, r, v) return s.handleExample(w, r)
} else if r.Method == http.MethodHead && r.URL.Path == "/" { } else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.ensureWebEnabled(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.ensureWebEnabled(s.handleWebConfig)(w, r, v) return s.handleWebConfig(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v) 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.ensureWebEnabled(s.handleStatic)(w, r, v) 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) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v) return s.handleDocs(w, r)
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
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.ensureWebEnabled(s.handleOptions)(w, r, v) return s.handleOptions(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { } 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) 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) {
@@ -310,21 +297,21 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
} 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) || externalTopicPathRegex.MatchString(r.URL.Path)) { } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
return s.ensureWebEnabled(s.handleTopic)(w, r, v) return s.handleTopic(w, r)
} }
return errHTTPNotFound return errHTTPNotFound
} }
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
if s.config.WebRootIsApp { if s.config.WebRootIsApp {
r.URL.Path = webAppIndex r.URL.Path = webAppIndex
} else { } else {
r.URL.Path = webHomeIndex r.URL.Path = webHomeIndex
} }
return s.handleStatic(w, r, v) return s.handleStatic(w, r)
} }
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too! unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
if unifiedpush { if unifiedpush {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -333,7 +320,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor)
return err return err
} }
r.URL.Path = webAppIndex r.URL.Path = webAppIndex
return s.handleStatic(w, r, v) return s.handleStatic(w, r)
} }
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
@@ -347,12 +334,12 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
return err return err
} }
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
_, err := io.WriteString(w, exampleSource) _, err := io.WriteString(w, exampleSource)
return err return err
} }
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error {
appRoot := "/" appRoot := "/"
if !s.config.WebRootIsApp { if !s.config.WebRootIsApp {
appRoot = "/app" appRoot = "/app"
@@ -380,13 +367,13 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
return nil return nil
} }
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) 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)
return nil return nil
} }
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error { func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
return nil return nil
} }
@@ -433,9 +420,6 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if err != nil { if err != nil {
return err return err
} }
if m.PollID != "" {
m = newPollRequestMessage(t.ID, m.PollID)
}
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
return err return err
} }
@@ -443,26 +427,24 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
m.Message = emptyMessageBody m.Message = emptyMessageBody
} }
delayed := m.Time > time.Now().Unix() delayed := m.Time > time.Now().Unix()
log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
logMessagePrefix(v, m), m.Event, len(m.Message), delayed, firebase, cache, unifiedpush, email)
if log.IsTrace() {
log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m))
}
if !delayed { if !delayed {
if err := t.Publish(v, m); err != nil { if err := t.Publish(m); err != nil {
return err return err
} }
if s.firebaseClient != nil && firebase { }
go s.sendToFirebase(v, m) if s.firebase != nil && firebase && !delayed {
} go func() {
if s.smtpSender != nil && email != "" { if err := s.firebase(m); err != nil {
go s.sendEmail(v, m, email) log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
} }
if s.config.UpstreamBaseURL != "" { }()
go s.forwardPollRequest(v, m) }
} if s.mailer != nil && email != "" && !delayed {
} else { go func() {
log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m)) if err := s.mailer.Send(v.ip, email, m); err != nil {
log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
}
}()
} }
if cache { if cache {
if err := s.messageCache.AddMessage(m); err != nil { if err := s.messageCache.AddMessage(m); err != nil {
@@ -480,44 +462,6 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return nil return nil
} }
func (s *Server) sendToFirebase(v *visitor, m *message) {
log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
if err := s.firebaseClient.Send(v, m); err != nil {
log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
}
}
func (s *Server) sendEmail(v *visitor, m *message, email string) {
log.Debug("%s Sending email to %s", logMessagePrefix(v, m), email)
if err := s.smtpSender.Send(v, m, email); err != nil {
log.Warn("%s Unable to send email to %s: %v", logMessagePrefix(v, m), email, err.Error())
}
}
func (s *Server) forwardPollRequest(v *visitor, m *message) {
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
log.Debug("%s Publishing poll request to %s", logMessagePrefix(v, m), forwardURL)
req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
if err != nil {
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
return
}
req.Header.Set("X-Poll-ID", m.ID)
var httpClient = &http.Client{
Timeout: time.Second * 10,
}
response, err := httpClient.Do(req)
if err != nil {
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
return
} else if response.StatusCode != http.StatusOK {
log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logMessagePrefix(v, m), response.StatusCode)
return
}
}
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) { func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
cache = readBoolParam(r, true, "x-cache", "cache") cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase") firebase = readBoolParam(r, true, "x-firebase", "firebase")
@@ -555,7 +499,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
return false, false, "", false, errHTTPTooManyRequestsLimitEmails return false, false, "", false, errHTTPTooManyRequestsLimitEmails
} }
} }
if s.smtpSender == nil && email != "" { if s.mailer == nil && email != "" {
return false, false, "", false, errHTTPBadRequestEmailDisabled return false, false, "", false, errHTTPBadRequestEmailDisabled
} }
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
@@ -590,7 +534,6 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
return false, false, "", false, errHTTPBadRequestDelayTooLarge return false, false, "", false, errHTTPBadRequestDelayTooLarge
} }
m.Time = delay.Unix() m.Time = delay.Unix()
m.Sender = v.ip // Important for rate limiting
} }
actionsStr := readParam(r, "x-actions", "actions", "action") actionsStr := readParam(r, "x-actions", "actions", "action")
if actionsStr != "" { if actionsStr != "" {
@@ -604,48 +547,32 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
firebase = false firebase = false
unifiedpush = true unifiedpush = true
} }
m.PollID = readParam(r, "x-poll-id", "poll-id")
if m.PollID != "" {
unifiedpush = false
cache = false
email = ""
}
return cache, firebase, email, unifiedpush, nil return cache, firebase, email, unifiedpush, nil
} }
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
// //
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/... // 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
// If a message is flagged as poll request, the body does not matter and is discarded
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
// If body is binary, encode as base64, if not do not encode // If body is binary, encode as base64, if not do not encode
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic // 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
// Body must be a message, because we attached an external URL // Body must be a message, because we attached an external URL
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic // 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
// Body must be attachment, because we passed a filename // Body must be attachment, because we passed a filename
// 5. curl -T file.txt ntfy.sh/mytopic // 4. curl -T file.txt ntfy.sh/mytopic
// 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
// 6. 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.PeekedReadCloser, unifiedpush bool) error { func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
if m.Event == pollRequestEvent { // Case 1 if unifiedpush {
return s.handleBodyDiscard(body) return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
} else if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
} else if m.Attachment != nil && m.Attachment.URL != "" { } else if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsTextMessage(m, body) // Case 3 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 4 return s.handleBodyAsAttachment(r, v, m, body) // Case 3
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 5 return s.handleBodyAsTextMessage(m, body) // Case 4
} }
return s.handleBodyAsAttachment(r, v, m, body) // Case 6 return s.handleBodyAsAttachment(r, v, m, body) // Case 5
}
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
_, err := io.Copy(io.Discard, body)
_ = body.Close()
return err
} }
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error { func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
@@ -692,7 +619,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
m.Attachment = &attachment{} m.Attachment = &attachment{}
} }
var ext string var ext string
m.Sender = 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.PeekedBytes, 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)
@@ -747,8 +674,6 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
} }
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error { func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
log.Debug("%s HTTP stream connection opened", logHTTPPrefix(v, r))
defer log.Debug("%s HTTP stream connection closed", logHTTPPrefix(v, r))
if err := v.SubscriptionAllowed(); err != nil { if err := v.SubscriptionAllowed(); err != nil {
return errHTTPTooManyRequestsLimitSubscriptions return errHTTPTooManyRequestsLimitSubscriptions
} }
@@ -762,7 +687,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
return err return err
} }
var wlock sync.Mutex var wlock sync.Mutex
sub := func(v *visitor, msg *message) error { sub := func(msg *message) error {
if !filters.Pass(msg) { if !filters.Pass(msg) {
return nil return nil
} }
@@ -783,7 +708,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset! w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
if poll { if poll {
return s.sendOldMessages(topics, since, scheduled, v, sub) return s.sendOldMessages(topics, since, scheduled, sub)
} }
subscriberIDs := make([]int, 0) subscriberIDs := make([]int, 0)
for _, t := range topics { for _, t := range topics {
@@ -794,10 +719,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
topics[i].Unsubscribe(subscriberID) // Order! topics[i].Unsubscribe(subscriberID) // Order!
} }
}() }()
if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
return err return err
} }
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
return err return err
} }
for { for {
@@ -805,9 +730,8 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
case <-r.Context().Done(): case <-r.Context().Done():
return nil return nil
case <-time.After(s.config.KeepaliveInterval): case <-time.After(s.config.KeepaliveInterval):
log.Trace("%s Sending keepalive message", logHTTPPrefix(v, r))
v.Keepalive() v.Keepalive()
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message if err := sub(newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
return err return err
} }
} }
@@ -822,8 +746,6 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return errHTTPTooManyRequestsLimitSubscriptions return errHTTPTooManyRequestsLimitSubscriptions
} }
defer v.RemoveSubscription() defer v.RemoveSubscription()
log.Debug("%s WebSocket connection opened", logHTTPPrefix(v, r))
defer log.Debug("%s WebSocket connection closed", logHTTPPrefix(v, r))
topics, topicsStr, err := s.topicsFromPath(r.URL.Path) topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
if err != nil { if err != nil {
return err return err
@@ -853,7 +775,6 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return err return err
} }
conn.SetPongHandler(func(appData string) error { conn.SetPongHandler(func(appData string) error {
log.Trace("%s Received WebSocket pong", logHTTPPrefix(v, r))
return conn.SetReadDeadline(time.Now().Add(pongWait)) return conn.SetReadDeadline(time.Now().Add(pongWait))
}) })
for { for {
@@ -870,7 +791,6 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil { if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
return err return err
} }
log.Trace("%s Sending WebSocket ping", logHTTPPrefix(v, r))
return conn.WriteMessage(websocket.PingMessage, nil) return conn.WriteMessage(websocket.PingMessage, nil)
} }
for { for {
@@ -885,7 +805,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
} }
} }
}) })
sub := func(v *visitor, msg *message) error { sub := func(msg *message) error {
if !filters.Pass(msg) { if !filters.Pass(msg) {
return nil return nil
} }
@@ -898,7 +818,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
} }
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if poll { if poll {
return s.sendOldMessages(topics, since, scheduled, v, sub) return s.sendOldMessages(topics, since, scheduled, sub)
} }
subscriberIDs := make([]int, 0) subscriberIDs := make([]int, 0)
for _, t := range topics { for _, t := range topics {
@@ -909,16 +829,15 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
topics[i].Unsubscribe(subscriberID) // Order! topics[i].Unsubscribe(subscriberID) // Order!
} }
}() }()
if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
return err return err
} }
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
return err return err
} }
err = g.Wait() err = g.Wait()
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Trace("%s WebSocket connection closed: %s", logHTTPPrefix(v, r), err.Error()) return nil // Normal closures are not errors
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
} }
return err return err
} }
@@ -937,7 +856,7 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
return return
} }
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error { func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, sub subscriber) error {
if since.IsNone() { if since.IsNone() {
return nil return nil
} }
@@ -947,7 +866,7 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b
return err return err
} }
for _, m := range messages { for _, m := range messages {
if err := sub(v, m); err != nil { if err := sub(m); err != nil {
return err return err
} }
} }
@@ -985,7 +904,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
return sinceNoMessages, errHTTPBadRequestSinceInvalid return sinceNoMessages, errHTTPBadRequestSinceInvalid
} }
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
@@ -1041,36 +960,28 @@ func (s *Server) updateStatsAndPrune() {
defer s.mu.Unlock() defer s.mu.Unlock()
// Expire visitors from rate visitors map // Expire visitors from rate visitors map
staleVisitors := 0
for ip, v := range s.visitors { for ip, v := range s.visitors {
if v.Stale() { if v.Stale() {
log.Debug("Deleting stale visitor %s", v.ip)
delete(s.visitors, ip) delete(s.visitors, ip)
staleVisitors++
} }
} }
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
// Delete expired attachments // Delete expired attachments
if s.fileCache != nil { if s.fileCache != nil {
ids, err := s.messageCache.AttachmentsExpired() ids, err := s.messageCache.AttachmentsExpired()
if err != nil { if err == nil {
log.Warn("Error retrieving expired attachments: %s", err.Error())
} else if len(ids) > 0 {
log.Debug("Manager: Deleting expired attachments: %v", ids)
if err := s.fileCache.Remove(ids...); err != nil { if err := s.fileCache.Remove(ids...); err != nil {
log.Warn("Error deleting attachments: %s", err.Error()) log.Printf("error while deleting attachments: %s", err.Error())
} }
} else { } else {
log.Debug("Manager: No expired attachments to delete") log.Printf("error retrieving expired attachments: %s", err.Error())
} }
} }
// Prune message cache // Prune message cache
olderThan := time.Now().Add(-1 * s.config.CacheDuration) olderThan := time.Now().Add(-1 * s.config.CacheDuration)
log.Debug("Manager: Pruning messages older than %s", olderThan.Format("2006-01-02 15:04:05"))
if err := s.messageCache.Prune(olderThan); err != nil { if err := s.messageCache.Prune(olderThan); err != nil {
log.Warn("Manager: Error pruning cache: %s", err.Error()) log.Printf("error pruning cache: %s", err.Error())
} }
// Prune old topics, remove subscriptions without subscribers // Prune old topics, remove subscriptions without subscribers
@@ -1079,7 +990,7 @@ func (s *Server) updateStatsAndPrune() {
subs := t.Subscribers() subs := t.Subscribers()
msgs, err := s.messageCache.MessageCount(t.ID) msgs, err := s.messageCache.MessageCount(t.ID)
if err != nil { if err != nil {
log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error()) log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error())
continue continue
} }
if msgs == 0 && subs == 0 { if msgs == 0 && subs == 0 {
@@ -1091,25 +1002,35 @@ func (s *Server) updateStatsAndPrune() {
} }
// Mail stats // Mail stats
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64 var mailSuccess, mailFailure int64
if s.smtpServerBackend != nil { if s.smtpBackend != nil {
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() mailSuccess, mailFailure = s.smtpBackend.Counts()
}
var sentMailTotal, sentMailSuccess, sentMailFailure int64
if s.smtpSender != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
} }
// Print stats // Print stats
log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)", log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
s.messages, messages, len(s.topics), subscribers, len(s.visitors), s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
receivedMailTotal, receivedMailSuccess, receivedMailFailure,
sentMailTotal, sentMailSuccess, sentMailFailure)
} }
func (s *Server) runSMTPServer() error { func (s *Server) runSMTPServer() error {
s.smtpServerBackend = newMailBackend(s.config, s.handle) sub := func(m *message) error {
s.smtpServer = smtp.NewServer(s.smtpServerBackend) url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message))
if err != nil {
return err
}
if m.Title != "" {
req.Header.Set("Title", m.Title)
}
rr := httptest.NewRecorder()
s.handle(rr, req)
if rr.Code != http.StatusOK {
return errors.New("error: " + rr.Body.String())
}
return nil
}
s.smtpBackend = newMailBackend(s.config, sub)
s.smtpServer = smtp.NewServer(s.smtpBackend)
s.smtpServer.Addr = s.config.SMTPServerListen s.smtpServer.Addr = s.config.SMTPServerListen
s.smtpServer.Domain = s.config.SMTPServerDomain s.smtpServer.Domain = s.config.SMTPServerDomain
s.smtpServer.ReadTimeout = 10 * time.Second s.smtpServer.ReadTimeout = 10 * time.Second
@@ -1131,29 +1052,28 @@ func (s *Server) runManager() {
} }
} }
func (s *Server) runFirebaseKeepaliver() { func (s *Server) runAtSender() {
if s.firebaseClient == nil {
return
}
v := newVisitor(s.config, s.messageCache, "0.0.0.0") // Background process, not a real visitor
for { for {
select { select {
case <-time.After(s.config.FirebaseKeepaliveInterval): case <-time.After(s.config.AtSenderInterval):
s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic)) if err := s.sendDelayedMessages(); err != nil {
case <-time.After(s.config.FirebasePollInterval): log.Printf("error sending scheduled messages: %s", err.Error())
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic)) }
case <-s.closeChan: case <-s.closeChan:
return return
} }
} }
} }
func (s *Server) runDelayedSender() { func (s *Server) runFirebaseKeepaliver() {
if s.firebase == nil {
return
}
for { for {
select { select {
case <-time.After(s.config.DelayedSenderInterval): case <-time.After(s.config.FirebaseKeepaliveInterval):
if err := s.sendDelayedMessages(); err != nil { if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
log.Warn("Error sending delayed messages: %s", err.Error()) log.Printf("error sending Firebase keepalive message: %s", err.Error())
} }
case <-s.closeChan: case <-s.closeChan:
return return
@@ -1162,40 +1082,27 @@ func (s *Server) runDelayedSender() {
} }
func (s *Server) sendDelayedMessages() error { func (s *Server) sendDelayedMessages() error {
s.mu.Lock()
defer s.mu.Unlock()
messages, err := s.messageCache.MessagesDue() messages, err := s.messageCache.MessagesDue()
if err != nil { if err != nil {
return err return err
} }
for _, m := range messages { for _, m := range messages {
v := s.visitorFromIP(m.Sender) t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
if err := s.sendDelayedMessage(v, m); err != nil { if ok {
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error()) if err := t.Publish(m); err != nil {
} log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error())
}
return nil
}
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
s.mu.Lock()
defer s.mu.Unlock()
log.Debug("%s Sending delayed message", logMessagePrefix(v, m))
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
if ok {
go func() {
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
if err := t.Publish(v, m); err != nil {
log.Warn("%s Unable to publish message: %v", logMessagePrefix(v, m), err.Error())
} }
}() }
} if s.firebase != nil { // Firebase subscribers may not show up in topics map
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map if err := s.firebase(m); err != nil {
go s.sendToFirebase(v, m) log.Printf("unable to publish to Firebase: %v", err.Error())
} }
if s.config.UpstreamBaseURL != "" { }
go s.forwardPollRequest(v, m) if err := s.messageCache.MarkPublished(m); err != nil {
} return err
if err := s.messageCache.MarkPublished(m); err != nil { }
return err
} }
return nil return nil
} }
@@ -1211,15 +1118,6 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
} }
} }
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if !s.config.EnableWeb {
return errHTTPNotFound
}
return next(w, r, v)
}
}
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers // 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. // 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 { func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
@@ -1297,13 +1195,13 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
username, password, ok := extractUserPass(r) username, password, ok := extractUserPass(r)
if ok { if ok {
if user, err = s.auth.Authenticate(username, password); err != nil { if user, err = s.auth.Authenticate(username, password); err != nil {
log.Info("authentication failed: %s", err.Error()) log.Printf("authentication failed: %s", err.Error())
return errHTTPUnauthorized return errHTTPUnauthorized
} }
} }
for _, t := range topics { for _, t := range topics {
if err := s.auth.Authorize(user, t.ID, perm); err != nil { if err := s.auth.Authorize(user, t.ID, perm); err != nil {
log.Info("unauthorized: %s", err.Error()) log.Printf("unauthorized: %s", err.Error())
return errHTTPForbidden return errHTTPForbidden
} }
} }
@@ -1335,6 +1233,8 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool
// visitor creates or retrieves a rate.Limiter for the given visitor. // visitor creates or retrieves a rate.Limiter for the given visitor.
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
func (s *Server) visitor(r *http.Request) *visitor { func (s *Server) visitor(r *http.Request) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
remoteAddr := r.RemoteAddr remoteAddr := r.RemoteAddr
ip, _, err := net.SplitHostPort(remoteAddr) ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil { if err != nil {
@@ -1343,12 +1243,6 @@ func (s *Server) visitor(r *http.Request) *visitor {
if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" { if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" {
ip = r.Header.Get("X-Forwarded-For") ip = r.Header.Get("X-Forwarded-For")
} }
return s.visitorFromIP(ip)
}
func (s *Server) visitorFromIP(ip string) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
v, exists := s.visitors[ip] v, exists := s.visitors[ip]
if !exists { if !exists {
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip) s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)

View File

@@ -1,7 +1,4 @@
# ntfy server config file # ntfy server config file
#
# Please refer to the documentation at https://ntfy.sh/docs/config/ for details.
# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com) # Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only). # This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
@@ -130,23 +127,10 @@
# manager-interval: "1m" # manager-interval: "1m"
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the # Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
# web app. If you self-host, you don't want to change this. # web app. If you self-host, you don't want to change this. Can be "app" (default) or "home".
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
# #
# web-root: app # web-root: app
# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
#
# iOS users:
# If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this:
# upstream-base-url: "https://ntfy.sh"
#
# If set, all incoming messages will publish a "poll_request" message to the configured upstream server, containing
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
#
# upstream-base-url:
# Rate limiting: Total number of topics before the server rejects new topics. # Rate limiting: Total number of topics before the server rejects new topics.
# #
# global-topic-limit: 15000 # global-topic-limit: 15000
@@ -178,11 +162,3 @@
# #
# visitor-attachment-total-size-limit: "100M" # visitor-attachment-total-size-limit: "100M"
# visitor-attachment-daily-bandwidth-limit: "500M" # visitor-attachment-daily-bandwidth-limit: "500M"
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
#
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
# debugging purposes, or your disk will fill up quickly.
#
# log-level: INFO

View File

@@ -3,117 +3,58 @@ package server
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors" firebase "firebase.google.com/go"
firebase "firebase.google.com/go/v4" "firebase.google.com/go/messaging"
"firebase.google.com/go/v4/messaging"
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"
"heckel.io/ntfy/auth" "heckel.io/ntfy/auth"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"strings" "strings"
) )
const ( const (
fcmMessageLimit = 4000 fcmMessageLimit = 4000
fcmApnsBodyMessageLimit = 100
) )
var ( // maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic") // The docs say the limit is 4000 characters, but during testing it wasn't quite clear
) // what fields matter; so we're just capping the serialized JSON to 4000 bytes.
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
// firebaseClient is a generic client that formats and sends messages to Firebase. s, err := json.Marshal(m)
// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
type firebaseClient struct {
sender firebaseSender
auther auth.Auther
}
func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
return &firebaseClient{
sender: sender,
auther: auther,
}
}
func (c *firebaseClient) Send(v *visitor, m *message) error {
if err := v.FirebaseAllowed(); err != nil {
return errFirebaseQuotaExceeded
}
fbm, err := toFirebaseMessage(m, c.auther)
if err != nil { if err != nil {
return err return m
} }
if log.IsTrace() { if len(s) > fcmMessageLimit {
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm)) over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
message, ok := m.Data["message"]
if ok && len(message) > over {
m.Data["truncated"] = "1"
m.Data["message"] = message[:len(message)-over]
}
} }
err = c.sender.Send(fbm) return m
if err == errFirebaseQuotaExceeded {
log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m))
v.FirebaseTemporarilyDeny()
}
return err
} }
// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging. func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
// In tests, this can be implemented with a mock.
type firebaseSender interface {
// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded
// if a rate limit has reached.
Send(m *messaging.Message) error
}
// firebaseSenderImpl is a firebaseSender that actually talks to Firebase
type firebaseSenderImpl struct {
client *messaging.Client
}
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
if err != nil { if err != nil {
return nil, err return nil, err
} }
client, err := fb.Messaging(context.Background()) msg, err := fb.Messaging(context.Background())
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &firebaseSenderImpl{ return func(m *message) error {
client: client, fbm, err := toFirebaseMessage(m, auther)
if err != nil {
return err
}
_, err = msg.Send(context.Background(), fbm)
return err
}, nil }, nil
} }
func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
_, err := c.client.Send(context.Background(), m)
if err != nil && messaging.IsQuotaExceeded(err) {
return errFirebaseQuotaExceeded
}
return err
}
// toFirebaseMessage converts a message to a Firebase message.
//
// Normal messages ("message"):
// - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields
// in the "data" attribute. In the Android app, we then turn those into a notification and display it.
// - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and
// message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the
// message in the Notification Service Extension.
//
// Keepalive messages ("keepalive"):
// - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died,
// e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).
// - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS
// does not allow any background or scheduled activity at all.
//
// Poll request messages ("poll_request"):
// - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
// to Firebase here. This is mainly for iOS to support self-hosted servers.
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) { func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
var apnsConfig *messaging.APNSConfig
switch m.Event { switch m.Event {
case keepaliveEvent, openEvent: case keepaliveEvent, openEvent:
data = map[string]string{ data = map[string]string{
@@ -122,17 +63,6 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
"event": m.Event, "event": m.Event,
"topic": m.Topic, "topic": m.Topic,
} }
apnsConfig = createAPNSBackgroundConfig(data)
case pollRequestEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"message": m.Message,
"poll_id": m.PollID,
}
apnsConfig = createAPNSAlertConfig(m, data)
case messageEvent: case messageEvent:
allowForward := true allowForward := true
if auther != nil { if auther != nil {
@@ -165,7 +95,6 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires) data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
data["attachment_url"] = m.Attachment.URL data["attachment_url"] = m.Attachment.URL
} }
apnsConfig = createAPNSAlertConfig(m, data)
} else { } else {
// If anonymous read for a topic is not allowed, we cannot send the message along // If anonymous read for a topic is not allowed, we cannot send the message along
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll. // via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
@@ -175,7 +104,6 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
"event": pollRequestEvent, "event": pollRequestEvent,
"topic": m.Topic, "topic": m.Topic,
} }
// TODO Handle APNS?
} }
} }
var androidConfig *messaging.AndroidConfig var androidConfig *messaging.AndroidConfig
@@ -188,85 +116,5 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
Topic: m.Topic, Topic: m.Topic,
Data: data, Data: data,
Android: androidConfig, Android: androidConfig,
APNS: apnsConfig,
}), nil }), nil
} }
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
s, err := json.Marshal(m)
if err != nil {
return m
}
if len(s) > fcmMessageLimit {
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
message, ok := m.Data["message"]
if ok && len(message) > over {
m.Data["truncated"] = "1"
m.Data["message"] = message[:len(message)-over]
}
}
return m
}
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
// Extension in iOS can modify the message.
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
apnsData := make(map[string]interface{})
for k, v := range data {
apnsData[k] = v
}
return &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
CustomData: apnsData,
Aps: &messaging.Aps{
MutableContent: true,
Alert: &messaging.ApsAlert{
Title: m.Title,
Body: maybeTruncateAPNSBodyMessage(m.Message),
},
},
},
}
}
// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only
// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll
// topic, which triggers the iOS app to poll all topics for changes.
//
// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
apnsData := make(map[string]interface{})
for k, v := range data {
apnsData[k] = v
}
return &messaging.APNSConfig{
Headers: map[string]string{
"apns-push-type": "background",
"apns-priority": "5",
},
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
ContentAvailable: true,
},
CustomData: apnsData,
},
}
}
// maybeTruncateAPNSBodyMessage truncates the body for APNS.
//
// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
func maybeTruncateAPNSBodyMessage(s string) string {
if len(s) >= fcmApnsBodyMessageLimit {
over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
return s[:len(s)-over] + "..."
}
return s
}

View File

@@ -3,12 +3,11 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"firebase.google.com/go/v4/messaging" "firebase.google.com/go/messaging"
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/auth" "heckel.io/ntfy/auth"
"strings" "strings"
"sync"
"testing" "testing"
) )
@@ -27,58 +26,12 @@ func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
return errors.New("unauthorized") return errors.New("unauthorized")
} }
type testFirebaseSender struct {
allowed int
messages []*messaging.Message
mu sync.Mutex
}
func newTestFirebaseSender(allowed int) *testFirebaseSender {
return &testFirebaseSender{
allowed: allowed,
messages: make([]*messaging.Message, 0),
}
}
func (s *testFirebaseSender) Send(m *messaging.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.messages)+1 > s.allowed {
return errFirebaseQuotaExceeded
}
s.messages = append(s.messages, m)
return nil
}
func (s *testFirebaseSender) Messages() []*messaging.Message {
s.mu.Lock()
defer s.mu.Unlock()
return append(make([]*messaging.Message, 0), s.messages...)
}
func TestToFirebaseMessage_Keepalive(t *testing.T) { func TestToFirebaseMessage_Keepalive(t *testing.T) {
m := newKeepaliveMessage("mytopic") m := newKeepaliveMessage("mytopic")
fbm, err := toFirebaseMessage(m, nil) fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic) require.Equal(t, "mytopic", fbm.Topic)
require.Nil(t, fbm.Android) require.Nil(t, fbm.Android)
require.Equal(t, &messaging.APNSConfig{
Headers: map[string]string{
"apns-push-type": "background",
"apns-priority": "5",
},
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
ContentAvailable: true,
},
CustomData: map[string]interface{}{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
},
},
}, fbm.APNS)
require.Equal(t, map[string]string{ require.Equal(t, map[string]string{
"id": m.ID, "id": m.ID,
"time": fmt.Sprintf("%d", m.Time), "time": fmt.Sprintf("%d", m.Time),
@@ -93,23 +46,6 @@ func TestToFirebaseMessage_Open(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic) require.Equal(t, "mytopic", fbm.Topic)
require.Nil(t, fbm.Android) require.Nil(t, fbm.Android)
require.Equal(t, &messaging.APNSConfig{
Headers: map[string]string{
"apns-push-type": "background",
"apns-priority": "5",
},
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
ContentAvailable: true,
},
CustomData: map[string]interface{}{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
},
},
}, fbm.APNS)
require.Equal(t, map[string]string{ require.Equal(t, map[string]string{
"id": m.ID, "id": m.ID,
"time": fmt.Sprintf("%d", m.Time), "time": fmt.Sprintf("%d", m.Time),
@@ -124,31 +60,13 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
m.Tags = []string{"tag 1", "tag2"} m.Tags = []string{"tag 1", "tag2"}
m.Click = "https://google.com" m.Click = "https://google.com"
m.Title = "some title" m.Title = "some title"
m.Actions = []*action{
{
ID: "123",
Action: "view",
Label: "Open page",
Clear: true,
URL: "https://ntfy.sh",
},
{
ID: "456",
Action: "http",
Label: "Close door",
URL: "https://door.com/close",
Method: "PUT",
Headers: map[string]string{
"really": "yes",
},
},
}
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "some file.jpg", Name: "some file.jpg",
Type: "image/jpeg", Type: "image/jpeg",
Size: 12345, Size: 12345,
Expires: 98765543, Expires: 98765543,
URL: "https://example.com/file.jpg", URL: "https://example.com/file.jpg",
Owner: "some-owner",
} }
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
require.Nil(t, err) require.Nil(t, err)
@@ -156,35 +74,6 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
require.Equal(t, &messaging.AndroidConfig{ require.Equal(t, &messaging.AndroidConfig{
Priority: "high", Priority: "high",
}, fbm.Android) }, fbm.Android)
require.Equal(t, &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
MutableContent: true,
Alert: &messaging.ApsAlert{
Title: "some title",
Body: "this is a message",
},
},
CustomData: map[string]interface{}{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
"title": "some title",
"message": "this is a message",
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
"encoding": "",
"attachment_name": "some file.jpg",
"attachment_type": "image/jpeg",
"attachment_size": "12345",
"attachment_expires": "98765543",
"attachment_url": "https://example.com/file.jpg",
},
},
}, fbm.APNS)
require.Equal(t, map[string]string{ require.Equal(t, map[string]string{
"id": m.ID, "id": m.ID,
"time": fmt.Sprintf("%d", m.Time), "time": fmt.Sprintf("%d", m.Time),
@@ -195,7 +84,6 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"click": "https://google.com", "click": "https://google.com",
"title": "some title", "title": "some title",
"message": "this is a message", "message": "this is a message",
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
"encoding": "", "encoding": "",
"attachment_name": "some file.jpg", "attachment_name": "some file.jpg",
"attachment_type": "image/jpeg", "attachment_type": "image/jpeg",
@@ -224,41 +112,6 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
}, fbm.Data) }, fbm.Data)
} }
func TestToFirebaseMessage_PollRequest(t *testing.T) {
m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
require.Nil(t, fbm.Android)
require.Equal(t, &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
MutableContent: true,
Alert: &messaging.ApsAlert{
Title: "",
Body: "New message",
},
},
CustomData: map[string]interface{}{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",
"topic": "mytopic",
"message": "New message",
"poll_id": "fOv6k1QbCzo6",
},
},
}, fbm.APNS)
require.Equal(t, map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",
"topic": "mytopic",
"message": "New message",
"poll_id": "fOv6k1QbCzo6",
}, fbm.Data)
}
func TestMaybeTruncateFCMMessage(t *testing.T) { func TestMaybeTruncateFCMMessage(t *testing.T) {
origMessage := strings.Repeat("this is a long string", 300) origMessage := strings.Repeat("this is a long string", 300)
origFCMMessage := &messaging.Message{ origFCMMessage := &messaging.Message{
@@ -315,22 +168,3 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage)) require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
} }
func TestToFirebaseSender_Abuse(t *testing.T) {
sender := &testFirebaseSender{allowed: 2}
client := newFirebaseClient(sender, &testAuther{})
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), "1.2.3.4")
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
require.Equal(t, 1, len(sender.Messages()))
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
require.Equal(t, 2, len(sender.Messages()))
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
require.Equal(t, 2, len(sender.Messages()))
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
require.Equal(t, 0, len(sender.Messages()))
}

View File

@@ -6,18 +6,18 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"math/rand" "math/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
) )
func TestServer_PublishAndPoll(t *testing.T) { func TestServer_PublishAndPoll(t *testing.T) {
@@ -54,21 +54,6 @@ func TestServer_PublishAndPoll(t *testing.T) {
require.Equal(t, "my second message", lines[1]) // \n -> " " require.Equal(t, "my second message", lines[1]) // \n -> " "
} }
func TestServer_PublishWithFirebase(t *testing.T) {
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
msg1 := toMessage(t, response.Body.String())
require.NotEmpty(t, msg1.ID)
require.Equal(t, "my first message", msg1.Message)
require.Equal(t, 1, len(sender.Messages()))
require.Equal(t, "my first message", sender.Messages()[0].Data["message"])
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body)
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
}
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.KeepaliveInterval = time.Second c.KeepaliveInterval = time.Second
@@ -177,40 +162,6 @@ func TestServer_StaticSites(t *testing.T) {
require.Contains(t, rr.Body.String(), "</html>") require.Contains(t, rr.Body.String(), "</html>")
} }
func TestServer_WebEnabled(t *testing.T) {
conf := newTestConfig(t)
conf.EnableWeb = false
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/example.html", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/config.js", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
require.Equal(t, 404, rr.Code)
conf2 := newTestConfig(t)
conf2.EnableWeb = true
s2 := newTestServer(t, conf2)
rr = request(t, s2, "GET", "/", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/example.html", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/config.js", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/static/css/home.css", "", nil)
require.Equal(t, 200, rr.Code)
}
func TestServer_PublishLargeMessage(t *testing.T) { func TestServer_PublishLargeMessage(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.AttachmentCacheDir = "" // Disable attachments c.AttachmentCacheDir = "" // Disable attachments
@@ -278,7 +229,7 @@ func TestServer_PublishNoCache(t *testing.T) {
func TestServer_PublishAt(t *testing.T) { func TestServer_PublishAt(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.MinDelay = time.Second c.MinDelay = time.Second
c.DelayedSenderInterval = 100 * time.Millisecond c.AtSenderInterval = 100 * time.Millisecond
s := newTestServer(t, c) s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
@@ -297,13 +248,6 @@ func TestServer_PublishAt(t *testing.T) {
messages = toMessages(t, response.Body.String()) messages = toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages)) require.Equal(t, 1, len(messages))
require.Equal(t, "a message", messages[0].Message) require.Equal(t, "a message", messages[0].Message)
require.Equal(t, "", messages[0].Sender) // Never return the sender!
messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "a message", messages[0].Message)
require.Equal(t, "9.9.9.9", messages[0].Sender) // It's stored in the DB though!
} }
func TestServer_PublishAtWithCacheError(t *testing.T) { func TestServer_PublishAtWithCacheError(t *testing.T) {
@@ -475,9 +419,29 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n ! require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
} }
func TestServer_PublishFirebase(t *testing.T) {
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
c := newTestConfig(t)
c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
s := newTestServer(t, c)
// Normal message
response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
// Keepalive message
require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
time.Sleep(500 * time.Millisecond) // Time for sends
}
func TestServer_PublishInvalidTopic(t *testing.T) { func TestServer_PublishInvalidTopic(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
s.smtpSender = &testMailer{} s.mailer = &testMailer{}
response := request(t, s, "PUT", "/docs", "fail", nil) response := request(t, s, "PUT", "/docs", "fail", nil)
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
} }
@@ -743,17 +707,13 @@ type testMailer struct {
mu sync.Mutex mu sync.Mutex
} }
func (t *testMailer) Send(v *visitor, m *message, to string) error { func (t *testMailer) Send(from, to string, m *message) error {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
t.count++ t.count++
return nil return nil
} }
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
return 0, 0, 0
}
func (t *testMailer) Count() int { func (t *testMailer) Count() int {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@@ -799,7 +759,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
s.smtpSender = &testMailer{} s.mailer = &testMailer{}
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com", "E-Mail": "test@example.com",
@@ -816,7 +776,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.VisitorEmailLimitReplenish = 500 * time.Millisecond c.VisitorEmailLimitReplenish = 500 * time.Millisecond
s := newTestServer(t, c) s := newTestServer(t, c)
s.smtpSender = &testMailer{} s.mailer = &testMailer{}
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com", "E-Mail": "test@example.com",
@@ -842,7 +802,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
func TestServer_PublishDelayedEmail_Fail(t *testing.T) { func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
s.smtpSender = &testMailer{} s.mailer = &testMailer{}
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
"E-Mail": "test@example.com", "E-Mail": "test@example.com",
"Delay": "20 min", "Delay": "20 min",
@@ -960,7 +920,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
func TestServer_PublishAsJSON_WithEmail(t *testing.T) { func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
mailer := &testMailer{} mailer := &testMailer{}
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
s.smtpSender = mailer s.mailer = mailer
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
response := request(t, s, "PUT", "/", body, nil) response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
@@ -1023,7 +983,7 @@ func TestServer_PublishAttachment(t *testing.T) {
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(179*time.Minute).Unix()) // Almost 3 hours 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.Sender) // 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))
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
@@ -1052,7 +1012,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
require.Equal(t, int64(21), msg.Attachment.Size) require.Equal(t, int64(21), 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(3*time.Hour).Unix())
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.Sender) // 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))
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
@@ -1079,7 +1039,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
require.Equal(t, "", msg.Attachment.Type) require.Equal(t, "", msg.Attachment.Type)
require.Equal(t, int64(0), msg.Attachment.Size) require.Equal(t, int64(0), msg.Attachment.Size)
require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, int64(0), msg.Attachment.Expires)
require.Equal(t, "", msg.Sender) 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.AttachmentBytesUsed("127.0.0.1") size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
@@ -1100,7 +1060,7 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
require.Equal(t, "", msg.Attachment.Type) require.Equal(t, "", msg.Attachment.Type)
require.Equal(t, int64(0), msg.Attachment.Size) require.Equal(t, int64(0), msg.Attachment.Size)
require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, int64(0), msg.Attachment.Expires)
require.Equal(t, "", msg.Sender) require.Equal(t, "", msg.Attachment.Owner)
} }
func TestServer_PublishAttachmentBadURL(t *testing.T) { func TestServer_PublishAttachmentBadURL(t *testing.T) {
@@ -1338,6 +1298,18 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
return &e return &e
} }
func firebaseServiceAccountFile(t *testing.T) string {
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
filename := filepath.Join(t.TempDir(), "firebase.json")
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
return filename
}
t.SkipNow()
return ""
}
func basicAuth(s string) string { func basicAuth(s string) string {
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s))) return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
} }

View File

@@ -4,62 +4,33 @@ import (
_ "embed" // required by go:embed _ "embed" // required by go:embed
"encoding/json" "encoding/json"
"fmt" "fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"mime" "mime"
"net" "net"
"net/smtp" "net/smtp"
"strings" "strings"
"sync"
"time" "time"
) )
type mailer interface { type mailer interface {
Send(v *visitor, m *message, to string) error Send(from, to string, m *message) error
Counts() (total int64, success int64, failure int64)
} }
type smtpSender struct { type smtpSender struct {
config *Config config *Config
success int64
failure int64
mu sync.Mutex
} }
func (s *smtpSender) Send(v *visitor, m *message, to string) error { func (s *smtpSender) Send(senderIP, to string, m *message) error {
return s.withCount(v, m, func() error { host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
if err != nil {
return err
}
message, err := formatMail(s.config.BaseURL, v.ip, s.config.SMTPSenderFrom, to, m)
if err != nil {
return err
}
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to)
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
})
}
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.success + s.failure, s.success, s.failure
}
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
err := fn()
s.mu.Lock()
defer s.mu.Unlock()
if err != nil { if err != nil {
log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error()) return err
s.failure++
} else {
s.success++
} }
return err message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
if err != nil {
return err
}
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
} }
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) { func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {

View File

@@ -3,15 +3,10 @@ package server
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
"io" "io"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/mail" "net/mail"
"strings" "strings"
"sync" "sync"
@@ -28,55 +23,49 @@ var (
// smtpBackend implements SMTP server methods. // smtpBackend implements SMTP server methods.
type smtpBackend struct { type smtpBackend struct {
config *Config config *Config
handler func(http.ResponseWriter, *http.Request) sub subscriber
success int64 success int64
failure int64 failure int64
mu sync.Mutex mu sync.Mutex
} }
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend { func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
return &smtpBackend{ return &smtpBackend{
config: conf, config: conf,
handler: handler, sub: sub,
} }
} }
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username) return &smtpSession{backend: b}, nil
return &smtpSession{backend: b, state: state}, nil
} }
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state)) return &smtpSession{backend: b}, nil
return &smtpSession{backend: b, state: state}, nil
} }
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) { func (b *smtpBackend) Counts() (success int64, failure int64) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
return b.success + b.failure, b.success, b.failure return b.success, b.failure
} }
// smtpSession is returned after EHLO. // smtpSession is returned after EHLO.
type smtpSession struct { type smtpSession struct {
backend *smtpBackend backend *smtpBackend
state *smtp.ConnectionState
topic string topic string
mu sync.Mutex mu sync.Mutex
} }
func (s *smtpSession) AuthPlain(username, password string) error { func (s *smtpSession) AuthPlain(username, password string) error {
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
return nil return nil
} }
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error { func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
return nil return nil
} }
func (s *smtpSession) Rcpt(to string) error { func (s *smtpSession) Rcpt(to string) error {
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
return s.withFailCount(func() error { return s.withFailCount(func() error {
conf := s.backend.config conf := s.backend.config
addressList, err := mail.ParseAddressList(to) addressList, err := mail.ParseAddressList(to)
@@ -113,11 +102,6 @@ func (s *smtpSession) Data(r io.Reader) error {
if err != nil { if err != nil {
return err return err
} }
if log.IsTrace() {
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
} else if log.IsDebug() {
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
}
msg, err := mail.ReadMessage(bytes.NewReader(b)) msg, err := mail.ReadMessage(bytes.NewReader(b))
if err != nil { if err != nil {
return err return err
@@ -144,7 +128,7 @@ func (s *smtpSession) Data(r io.Reader) error {
m.Message = m.Title // Flip them, this makes more sense m.Message = m.Title // Flip them, this makes more sense
m.Title = "" m.Title = ""
} }
if err := s.publishMessage(m); err != nil { if err := s.backend.sub(m); err != nil {
return err return err
} }
s.backend.mu.Lock() s.backend.mu.Lock()
@@ -154,33 +138,6 @@ func (s *smtpSession) Data(r io.Reader) error {
}) })
} }
func (s *smtpSession) publishMessage(m *message) error {
// Extract remote address (for rate limiting)
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
if err != nil {
remoteAddr = s.state.RemoteAddr.String()
}
// Call HTTP handler with fake HTTP request
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
req.RequestURI = "/" + m.Topic // just for the logs
req.RemoteAddr = remoteAddr // rate limiting!!
req.Header.Set("X-Forwarded-For", remoteAddr)
if err != nil {
return err
}
if m.Title != "" {
req.Header.Set("Title", m.Title)
}
rr := httptest.NewRecorder()
s.backend.handler(rr, req)
if rr.Code != http.StatusOK {
return errors.New("error: " + rr.Body.String())
}
return nil
}
func (s *smtpSession) Reset() { func (s *smtpSession) Reset() {
s.mu.Lock() s.mu.Lock()
s.topic = "" s.topic = ""
@@ -196,56 +153,43 @@ func (s *smtpSession) withFailCount(fn func() error) error {
s.backend.mu.Lock() s.backend.mu.Lock()
defer s.backend.mu.Unlock() defer s.backend.mu.Unlock()
if err != nil { if err != nil {
// Almost all of these errors are parse errors, and user input errors.
// We do not want to spam the log with WARN messages.
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
s.backend.failure++ s.backend.failure++
} }
return err return err
} }
func readMailBody(msg *mail.Message) (string, error) { func readMailBody(msg *mail.Message) (string, error) {
if msg.Header.Get("Content-Type") == "" {
return readPlainTextMailBody(msg)
}
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil { if err != nil {
return "", err return "", err
} }
if contentType == "text/plain" { if contentType == "text/plain" {
return readPlainTextMailBody(msg) body, err := io.ReadAll(msg.Body)
} else if strings.HasPrefix(contentType, "multipart/") {
return readMultipartMailBody(msg, params)
}
return "", errUnsupportedContentType
}
func readPlainTextMailBody(msg *mail.Message) (string, error) {
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if partContentType != "text/plain" {
continue
}
body, err := io.ReadAll(part)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(body), nil return string(body), nil
} }
if strings.HasPrefix(contentType, "multipart/") {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if partContentType != "text/plain" {
continue
}
body, err := io.ReadAll(part)
if err != nil {
return "", err
}
return string(body), nil
}
}
return "", errUnsupportedContentType
} }

View File

@@ -3,9 +3,6 @@ package server
import ( import (
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"io"
"net"
"net/http"
"strings" "strings"
"testing" "testing"
) )
@@ -30,12 +27,13 @@ Content-Type: text/html; charset="UTF-8"
<div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div> <div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div>
--000000000000f3320b05d42915c9--` --000000000000f3320b05d42915c9--`
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { _, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "and one more", r.Header.Get("Title")) require.Equal(t, "and one more", m.Title)
require.Equal(t, "what's up", readAll(t, r.Body)) require.Equal(t, "what's up", m.Message)
return nil
}) })
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email))) require.Nil(t, session.Data(strings.NewReader(email)))
@@ -61,12 +59,13 @@ Content-Type: text/html; charset="UTF-8"
<div dir="ltr"><br></div> <div dir="ltr"><br></div>
--000000000000bcf4a405d429f8d4--` --000000000000bcf4a405d429f8d4--`
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { _, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "/emailtest", r.URL.Path) require.Equal(t, "emailtest", m.Topic)
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body require.Equal(t, "", m.Title) // We flipped message and body
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body)) require.Equal(t, "This email has a subject but no body", m.Message)
return nil
}) })
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh")) require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email))) require.Nil(t, session.Data(strings.NewReader(email)))
@@ -82,30 +81,14 @@ Content-Type: text/plain; charset="UTF-8"
what's up what's up
` `
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { conf, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "and one more", r.Header.Get("Title")) require.Equal(t, "and one more", m.Title)
require.Equal(t, "what's up", readAll(t, r.Body)) require.Equal(t, "what's up", m.Message)
return nil
}) })
conf.SMTPServerAddrPrefix = "" conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
}
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
email := `Subject: Very short mail
what's up
`
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Very short mail", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body))
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email))) require.Nil(t, session.Data(strings.NewReader(email)))
@@ -120,10 +103,11 @@ Content-Type: text/plain; charset="UTF-8"
what's up what's up
` `
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { _, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title")) require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
return nil
}) })
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email))) require.Nil(t, session.Data(strings.NewReader(email)))
@@ -138,7 +122,7 @@ To: mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8" Content-Type: text/plain; charset="UTF-8"
you know this is a string. you know this is a string.
it's a long string. it's a long string.
it's supposed to be longer than the max message length it's supposed to be longer than the max message length
which is 4096 bytes, which is 4096 bytes,
it used to be 512 bytes, but I increased that for the UnifiedPush support it used to be 512 bytes, but I increased that for the UnifiedPush support
@@ -202,9 +186,9 @@ BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
that should do it that should do it
` `
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { conf, backend := newTestBackend(t, func(m *message) error {
expected := `you know this is a string. expected := `you know this is a string.
it's a long string. it's a long string.
it's supposed to be longer than the max message length it's supposed to be longer than the max message length
which is 4096 bytes, which is 4096 bytes,
it used to be 512 bytes, but I increased that for the UnifiedPush support it used to be 512 bytes, but I increased that for the UnifiedPush support
@@ -264,12 +248,13 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
...................................................................... ......................................................................
...................................................................... ......................................................................
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBB` BBBBBBBBBBBBBBBBBBBBBBBB`
require.Equal(t, 4096, len(expected)) // Sanity check require.Equal(t, 4096, len(expected)) // Sanity check
require.Equal(t, expected, readAll(t, r.Body)) require.Equal(t, expected, m.Message)
return nil
}) })
conf.SMTPServerAddrPrefix = "" conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email))) require.Nil(t, session.Data(strings.NewReader(email)))
@@ -285,41 +270,21 @@ Content-Type: text/SOMETHINGELSE
what's up what's up
` `
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) { conf, backend := newTestBackend(t, func(m *message) error {
// Nothing. return nil
}) })
conf.SMTPServerAddrPrefix = "" conf.SMTPServerAddrPrefix = ""
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass") session, _ := backend.Login(nil, "user", "pass")
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email))) require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
} }
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) { func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
conf := newTestConfig(t) conf := newTestConfig(t)
conf.SMTPServerListen = ":25" conf.SMTPServerListen = ":25"
conf.SMTPServerDomain = "ntfy.sh" conf.SMTPServerDomain = "ntfy.sh"
conf.SMTPServerAddrPrefix = "ntfy-" conf.SMTPServerAddrPrefix = "ntfy-"
backend := newMailBackend(conf, handler) backend := newMailBackend(conf, sub)
return conf, backend return conf, backend
} }
func readAll(t *testing.T, rc io.ReadCloser) string {
b, err := io.ReadAll(rc)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
ip, err := net.ResolveIPAddr("ip", remoteAddr)
if err != nil {
t.Fatal(err)
}
return &smtp.ConnectionState{
Hostname: "myhostname",
LocalAddr: ip,
RemoteAddr: ip,
}
}

View File

@@ -1,7 +1,7 @@
package server package server
import ( import (
"heckel.io/ntfy/log" "log"
"math/rand" "math/rand"
"sync" "sync"
) )
@@ -15,7 +15,7 @@ type topic struct {
} }
// subscriber is a function that is called for every new message on a topic // subscriber is a function that is called for every new message on a topic
type subscriber func(v *visitor, msg *message) error type subscriber func(msg *message) error
// newTopic creates a new topic // newTopic creates a new topic
func newTopic(id string) *topic { func newTopic(id string) *topic {
@@ -42,19 +42,14 @@ func (t *topic) Unsubscribe(id int) {
} }
// Publish asynchronously publishes to all subscribers // Publish asynchronously publishes to all subscribers
func (t *topic) Publish(v *visitor, m *message) error { func (t *topic) Publish(m *message) error {
go func() { go func() {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
if len(t.subscribers) > 0 { for _, s := range t.subscribers {
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers)) if err := s(m); err != nil {
for _, s := range t.subscribers { log.Printf("error publishing message to subscriber")
if err := s(v, m); err != nil {
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
}
} }
} else {
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
} }
}() }()
return nil return nil

View File

@@ -24,15 +24,13 @@ type message struct {
Time int64 `json:"time"` // Unix time in seconds Time int64 `json:"time"` // Unix time in seconds
Event string `json:"event"` // One of the above Event string `json:"event"` // One of the above
Topic string `json:"topic"` Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"` Click string `json:"click,omitempty"`
Actions []*action `json:"actions,omitempty"` Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"` Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"` Title string `json:"title,omitempty"`
Sender string `json:"-"` // IP address of uploader, used for rate limiting Message string `json:"message,omitempty"`
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
} }
@@ -42,6 +40,7 @@ type attachment struct {
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
Expires int64 `json:"expires,omitempty"` Expires int64 `json:"expires,omitempty"`
URL string `json:"url"` URL string `json:"url"`
Owner string `json:"-"` // IP address of uploader, used for rate limiting
} }
type action struct { type action struct {
@@ -85,11 +84,14 @@ type messageEncoder func(msg *message) (string, error)
// newMessage creates a new message with the current timestamp // newMessage creates a new message with the current timestamp
func newMessage(event, topic, msg string) *message { func newMessage(event, topic, msg string) *message {
return &message{ return &message{
ID: util.RandomString(messageIDLength), ID: util.RandomString(messageIDLength),
Time: time.Now().Unix(), Time: time.Now().Unix(),
Event: event, Event: event,
Topic: topic, Topic: topic,
Message: msg, Priority: 0,
Tags: nil,
Title: "",
Message: msg,
} }
} }
@@ -108,13 +110,6 @@ func newDefaultMessage(topic, msg string) *message {
return newMessage(messageEvent, topic, msg) return newMessage(messageEvent, topic, msg)
} }
// newPollRequestMessage is a convenience method to create a poll request message
func newPollRequestMessage(topic, pollID string) *message {
m := newMessage(pollRequestEvent, topic, newMessageBody)
m.PollID = pollID
return m
}
func validMessageID(s string) bool { func validMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength) return util.ValidRandomString(s, messageIDLength)
} }
@@ -158,7 +153,6 @@ var (
) )
type queryFilter struct { type queryFilter struct {
ID string
Message string Message string
Title string Title string
Tags []string Tags []string
@@ -166,7 +160,6 @@ type queryFilter struct {
} }
func parseQueryFilters(r *http.Request) (*queryFilter, error) { func parseQueryFilters(r *http.Request) (*queryFilter, error) {
idFilter := readParam(r, "x-id", "id")
messageFilter := readParam(r, "x-message", "message", "m") messageFilter := readParam(r, "x-message", "message", "m")
titleFilter := readParam(r, "x-title", "title", "t") titleFilter := readParam(r, "x-title", "title", "t")
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",") tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
@@ -179,7 +172,6 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
priorityFilter = append(priorityFilter, priority) priorityFilter = append(priorityFilter, priority)
} }
return &queryFilter{ return &queryFilter{
ID: idFilter,
Message: messageFilter, Message: messageFilter,
Title: titleFilter, Title: titleFilter,
Tags: tagsFilter, Tags: tagsFilter,
@@ -190,11 +182,11 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
func (q *queryFilter) Pass(msg *message) bool { func (q *queryFilter) Pass(msg *message) bool {
if msg.Event != messageEvent { if msg.Event != messageEvent {
return true // filters only apply to messages return true // filters only apply to messages
} else if q.ID != "" && msg.ID != q.ID { }
if q.Message != "" && msg.Message != q.Message {
return false return false
} else if q.Message != "" && msg.Message != q.Message { }
return false if q.Title != "" && msg.Title != q.Title {
} else if q.Title != "" && msg.Title != q.Title {
return false return false
} }
messagePriority := msg.Priority messagePriority := msg.Priority

View File

@@ -1,8 +1,6 @@
package server package server
import ( import (
"fmt"
"github.com/emersion/go-smtp"
"net/http" "net/http"
"strings" "strings"
) )
@@ -42,19 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
} }
return "" return ""
} }
func logMessagePrefix(v *visitor, m *message) string {
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
}
func logHTTPPrefix(v *visitor, r *http.Request) string {
requestURI := r.RequestURI
if requestURI == "" {
requestURI = r.URL.Path
}
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
}
func logSMTPPrefix(state *smtp.ConnectionState) string {
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
}

View File

@@ -28,7 +28,6 @@ type visitor struct {
emails *rate.Limiter emails *rate.Limiter
subscriptions util.Limiter subscriptions util.Limiter
bandwidth util.Limiter bandwidth util.Limiter
firebase time.Time // Next allowed Firebase message
seen time.Time seen time.Time
mu sync.Mutex mu sync.Mutex
} }
@@ -49,11 +48,14 @@ func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
firebase: time.Unix(0, 0),
seen: time.Now(), seen: time.Now(),
} }
} }
func (v *visitor) IP() string {
return v.ip
}
func (v *visitor) RequestAllowed() error { func (v *visitor) RequestAllowed() error {
if !v.requests.Allow() { if !v.requests.Allow() {
return errVisitorLimitReached return errVisitorLimitReached
@@ -61,21 +63,6 @@ func (v *visitor) RequestAllowed() error {
return nil return nil
} }
func (v *visitor) FirebaseAllowed() error {
v.mu.Lock()
defer v.mu.Unlock()
if time.Now().Before(v.firebase) {
return errVisitorLimitReached
}
return nil
}
func (v *visitor) FirebaseTemporarilyDeny() {
v.mu.Lock()
defer v.mu.Unlock()
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
}
func (v *visitor) EmailAllowed() error { func (v *visitor) EmailAllowed() error {
if !v.emails.Allow() { if !v.emails.Allow() {
return errVisitorLimitReached return errVisitorLimitReached

View File

@@ -2,8 +2,8 @@ package main
import ( import (
"context" "context"
firebase "firebase.google.com/go/v4" firebase "firebase.google.com/go"
"firebase.google.com/go/v4/messaging" "firebase.google.com/go/messaging"
"flag" "flag"
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"

View File

@@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
sw := NewContentTypeWriter(rr, "") sw := NewContentTypeWriter(rr, "")
randomBytes := make([]byte, 199) randomBytes := make([]byte, 199)
rand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings rand.Read(randomBytes)
sw.Write(randomBytes) sw.Write(randomBytes)
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type")) require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
} }

View File

@@ -2,7 +2,6 @@ package util
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
@@ -184,6 +183,11 @@ func PriorityString(priority int) (string, error) {
} }
} }
// ExpandHome replaces "~" with the user's home directory
func ExpandHome(path string) string {
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
}
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https:// // ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
func ShortTopicURL(s string) string { func ShortTopicURL(s string) string {
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
@@ -265,16 +269,3 @@ func ReadPassword(in io.Reader) ([]byte, error) {
func BasicAuth(user, pass string) string { func BasicAuth(user, pass string) string {
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass)))) return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
} }
// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
// This is useful for logging purposes where a failure doesn't matter that much.
func MaybeMarshalJSON(v interface{}) string {
jsonBytes, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "<cannot serialize>"
}
if len(jsonBytes) > 5000 {
return string(jsonBytes)[:5000]
}
return string(jsonBytes)
}

View File

@@ -3,6 +3,7 @@ package util
import ( import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"io/ioutil" "io/ioutil"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -74,6 +75,14 @@ func TestSplitNoEmpty(t *testing.T) {
require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ",")) require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ","))
} }
func TestExpandHome_WithTilde(t *testing.T) {
require.Equal(t, os.Getenv("HOME")+"/this/is/a/path", ExpandHome("~/this/is/a/path"))
}
func TestExpandHome_NoTilde(t *testing.T) {
require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path"))
}
func TestParsePriority(t *testing.T) { func TestParsePriority(t *testing.T) {
priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"} priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"}
expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5} expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5}

9465
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,35 @@
{ {
"name": "ntfy", "name": "ntfy",
"version": "1.0.0", "version": "1.0.0",
"url": "https://github.com/binwiederhier/ntfy",
"author": "Philipp C. Heckel <philipp.heckel@gmail.com>",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"start-electron": "concurrently \"BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"",
"build": "react-scripts build", "build": "react-scripts build",
"build-electron": "react-scripts build --em.main=build/electron.js && electron-builder",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"main": "public/electron.js",
"homepage": "./",
"build": {
"appId": "io.heckel.ntfy",
"files": [
"build/**/*",
"node_modules/**/*",
"public/**/*"
],
"directories":{
"buildResources": "assets"
},
"linux": {
"target": [
"AppImage"
]
}
},
"dependencies": { "dependencies": {
"@emotion/react": "^11.8.2", "@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.8.1",
@@ -15,6 +37,7 @@
"@mui/material": "latest", "@mui/material": "latest",
"dexie": "^3.2.1", "dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1", "dexie-react-hooks": "^1.1.1",
"electron-is-dev": "^2.0.0",
"i18next": "^21.6.14", "i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0", "i18next-http-backend": "^1.4.0",
@@ -28,6 +51,12 @@
"stacktrace-gps": "^3.0.4", "stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2" "stacktrace-js": "^2.0.2"
}, },
"devDependencies": {
"concurrently": "^7.1.0",
"electron": "^18.2.0",
"electron-builder": "^23.0.3",
"wait-on": "^6.0.1"
},
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",

42
web/public/electron.js Normal file
View File

@@ -0,0 +1,42 @@
const { app, BrowserWindow, Tray, Menu, nativeImage } = require('electron');
const isDev = require('electron-is-dev');
const path = require('path');
let mainWindow;
const createWindow = () => {
mainWindow = new BrowserWindow({width: 900, height: 680});
mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../build/index.html')}`);
mainWindow.on('closed', () => mainWindow = null);
};
const createTray = () => {
const icon = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAACsZJREFUWAmtWFlsXFcZ/u82++Jt7IyT2Em6ZFHTpAtWIzspEgjEUhA8VNAiIYEQUvuABBIUwUMkQIVKPCIoEiABLShISEBbhFJwIGRpIKRpbNeJ7bh2HHvssR3PPnPnLnzfmRlju6EQqUc+c++c8y/fv54z1uQOh+/7Glh0TD59TE/TND7lnfa4/64OKsM071QoeZpA/y9WWvk/B4XCC06TUC+Xyw8HTXNQ1+Ww6PpOrMebewXxvBueJ6/XHOdMJBL5J9Y97m2R0SS/wweE6JxkGx5dilWr1S/7dXsEa2o4+LyFmcFcaL5zbX3Y9gh5hpeWYpSB9XV5/H678V89BGYDXnHJlCsWn4gHrGc1K9CXxferOdvPOOKUfF8cH7nUyCtklQZXih/VNNlmirk3GdBSoIcRswW7/vVkLPYi5W2Uze8bh7J+4wLfh4dViFx5/nmrUi7/MhGNvrCkBfpeWqnW/7BUdadqntQ8zwr6vhUV34xpYnDynWvcmwQNaclDXsqgLMqkocPDw7fNx7d5qIX+/PmJxKGD6VdDkeh7ztyqOFfrokGCEWiiZ1mp0uITnuKAosaT7+pNxMYTyefutcQfbA+b1XLpH5fnF97/yD335Fu6mqTqsclDINBVmI4fDxw80KPAvJSt1MZtMcLiGxYUu83p4UkgnJZlqcl3LAj3WnTkIS9lUBYNPJjueVWgg7qocyOgliFqjZsg8gq5tRdiieQTf1gq15Y8CUbRZtyWOzZwc8lEqS3PTCtgqd13ieO68BQ2uNl64tXAewktrFuX2mPdkWAxn3sxnmx7sqUTJGqso8MGS9tbXFz8DMH8bblUX3T9QARVi8RV8qljfcJy0zRlaf6mzHEuzEtmekqCoZB4rqp0OmudHtUnlEWZlE0d1EWd1N3EozourcO65pw4eTIZQTW9VazJtbqvw9XwKVFQMsKDBuNhtp4uvGGFI+IDgKnpMjYyIis3ZsQMBIR7pONsIaMsyqRs6ohY1rPUSd3EQFDqo+kdZ3Fh4aupbdu+99uFQr2A1CBs4uEAjZjIFUMHi4dVxMXzCdCXQj4vBrwVCofl0ulTcv/DAxJJJBUPc8mpoyI2JDw7bFyT+ifTcSubyXytJ51+roWBxwG9Q73WWjZ7eSUU3//nXM0NI+x0PBGrTSgsLS9JFuFxHFrvSqIrJV279gi6tjiVspTza3JjZhY+0CQZj0mlWJSeHTslCro6eFqymCcVVN77kkGjs1p4sy2VOoSlOrFwT+XR+PjkgGaZ+ycKVbRTYUdVrmaImCvzk1dlFCEJdHRJ284+ie/ol0h7p7jFvExcvCCXzp2Rqem3pAMAiqWS6JGYhFI9Mjo6KjevXVUyKEuFHrKpY6JQ8TXT3D8+OTkAHBw6o6LCFo9ag3o4JtlCyTHEt5AxKvS6YUi5kJeZG3Py0NAxlLcJ9xti+K7Mjo/JfGZRuvv6Ze+9+yWEhDZAvzg3JyhX2d6/S7q6e+TimdOS7ElLKBZDwqvmj6rztayr1fVI1IoXi4PAcYZY1tPEEO1wEVlXgRFBDcmIXTqJsS+XyhKLJ5A/OpIVXXptWUYv/UvaenfIocEhMQ2EzHHErlXFCgQl3paU1eVl6QAY8sQTCSmVihKJx1V/ogvgIYF/pACdcMBhqONoHhF88/2d+bojyA6cRvje2IdFjoSjUSnBS8hgyS9lZOzKFdmPxO3o6gQIGzwuDn1dVSCtCKPy1pZXlATXqUsVYMLRmKo87vP4Y1ioqwCdCegmMYx3W/VPn8RrSDwwIMMbcEjkYo29JZVOy+ybI7K4eksODx1VSqvligpReSVLgySM/FI5h2q062jNyL3s7FtoAyGJIlx1225UmwJF6aJRJ3XzHXO9bWvsJa3jQFlBJkz6iuXdu32HzM7MyP0PPNgAU6ko4Qzp6b+flr8MD9OYJg9CwtzL5+T65ITs2bsP3mGxN/ZbBcOn0sk20gAkLQ+huXpFi8vkoY9AoyDjxTR1mbo6Ltt275HpN0dlNxQE40mVM8Ajjxx9VAGhAvQR1akZFCq799ADysMuQqOxh2FNmamEaz51ItGLfFD9+oUJoZkLowHoFA2mljUacqOMflKuVmHpfmnfvlMuvXZeStmMBIMhcWEdjgFJtrUjXI0KchAuAg0ilxLJNoRVBxhIBm0TjjKAuqjTqTs3CQZ6QUUMGFW7eiWMUg6w+yo8YMW7DqtqlZLkUDV2ISfd29KyDwk9MjYmMyOXxQIIKuShqo4VGFNBEgeDQYqVam5N5tEePFQgURIUBCsd1EWd1XrtDUUMLARD9bKaK5ytQ2Gb75g8WMiEP6VkfnZGevv6UF1vSBW5E0PFDAweFRvlfun8WVmamhDNrkmweQ0pwaPt6M4m8mgKTTFXqcrV0ZH1FKBg6qAu6qTuJiCV1Cp2Q0NDr9Uq5Ym+oMEDlSewsoRwrVBEaij7AJ4s7zrOpumxEdm15y6558GHJVe1Zezy6zJx6aJkpq5JFB4z6zVZmBiX1VWUP0IY4CFMYcpQdZ3xqIs6oftCE5DHKwd0q/tzOV8svdDb3nk8VnG9qmgQC0ZURz8Ur91alXgSByZ6ES9kZZTr/PR16UOCh+7dq0CWyyXJ4xqCQ0nKt9YQSlPue2gAeYZzD7yNLk0wmqAreb2WYSxAJ8Dget64wxtEBlDaqVOn/K5dB67t6+t5MhoMJuc8w8UPKiQ9CQR9JK5czhZAQxPt7TKF3OiAIisUViAD2Lg5d0P2HDgoKeRaW0enyqVwBJcO5fFG5dqa7h406qaeX8384uTZL5w9+UqxhYHFp0YLIYA9ddfu3T+4UJF6Rg+YAc9D0+RoIGP1ULhpWspr10evyK7+ftWTrk9PS/++A9KZSm26cih2mMOErem6n/ZsZwA2TM/MPHXs2LEftnSTbh0Q36mIIbx44cLvOnu3f+xUwbWLmoHTCUlF6g2jBQo/GnFrnGNqSHdvr+rIKGMW1KahwEBdzHft98aNwMr8zd8/NDDwccihc0hLi3GubRjY0Bm6H19fPvnZI4c/fHd7PJ2peXYZ+WQ26JufZELjQ6lbAQtnWre0d3apY8TFIdtAo+Qri6mupsB49lBMC+QXF0YefObZT8j0eKWlswVjEyCCOXHihPGb575VCvVuf3lvetsH9rXF0rla3cnhpoIGjgsUPhR3I4TMKYJQV1Z6WO02aEjHa5mNe3OPW3OPRHVrbXFh9Ocvv/KR1372owx1Pf3005uc35Ddgtd8rsf06IdS5777zZ+mUqmPzjm6TPpmvayZOq4LyATeCzkanmiy4qEuC/yXiO8CSMRzvLs1x9phepLNZl868sy3Pyen/5hd1/EfRvWmuvSWNeaRS/RkPDI4+NjE1NSXEoXlpaNB1zqo20abi59/vu/UfM2pie7WUDVq8l3wTwnskeZ+zTbIQ17KoCzKpGzq2KqX32/roRbh8ePHdUzl0s9/5Rv9n/7go19MxCKfCkZiu3V06wrO5gocxL7Dgd/IEobEMH6rejg+auXidL5Y/vWv/vTX53/y/e/MkGajTH7fOt4RUJOY1df4RdtY6ICFRzqTySOhUOA+3Ai3o31H1ZbnlXBruFmt2iMrudy5xx9//BzWV7nXDBGN2xpjbt/5oGUEdhtO3iD47xZOvm8a5CHvpsV38wsUaMwBWsz3rbK5xr0mzdv2t9Jv/f5vhsF4J+Q63IUAAAAASUVORK5CYII=');
const tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
{ label: 'Quit' }
]);
tray.setContextMenu(contextMenu);
tray.setToolTip('This is my application');
tray.setTitle('This is my title');
}
app.on('ready', () => {
createWindow();
createTray();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});

View File

@@ -110,7 +110,7 @@
<p> <p>
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a> <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a> <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a> <a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="static/img/badge-appstore.png"></a>
</p> </p>
<p> <p>
Here's a video showing the app in action: Here's a video showing the app in action:

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ntfy web</title> <title>ntfy web</title>
<base href="/">
<!-- Mobile view --> <!-- Mobile view -->
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">

View File

@@ -14,7 +14,7 @@
"publish_dialog_progress_uploading": "Изпращане…", "publish_dialog_progress_uploading": "Изпращане…",
"publish_dialog_progress_uploading_detail": "Изпращане {{loaded}}/{{total}} ({{percent}}%)…", "publish_dialog_progress_uploading_detail": "Изпращане {{loaded}}/{{total}} ({{percent}}%)…",
"publish_dialog_message_published": "Известието е публикувано", "publish_dialog_message_published": "Известието е публикувано",
"publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл и квотата, остават {{remainingBytes}}", "publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението и квотата от {{fileSizeLimit}}, оставащи {{remainingBytes}}",
"publish_dialog_message_label": "Съобщение", "publish_dialog_message_label": "Съобщение",
"publish_dialog_message_placeholder": "Въведете съобщение", "publish_dialog_message_placeholder": "Въведете съобщение",
"publish_dialog_other_features": "Други възможности:", "publish_dialog_other_features": "Други възможности:",
@@ -43,7 +43,7 @@
"message_bar_type_message": "Въведете съобщение", "message_bar_type_message": "Въведете съобщение",
"message_bar_error_publishing": "Грешка при изпращане на известието", "message_bar_error_publishing": "Грешка при изпращане на известието",
"notifications_copied_to_clipboard": "Копирано в междинната памет", "notifications_copied_to_clipboard": "Копирано в междинната памет",
"notifications_attachment_link_expired": "препратката за изтегляне е с изтекла давност", "notifications_attachment_link_expired": "препратката за изтегляне е невалидна",
"nav_button_settings": "Настройки", "nav_button_settings": "Настройки",
"nav_button_documentation": "Ръководство", "nav_button_documentation": "Ръководство",
"nav_button_subscribe": "Абониране за тема", "nav_button_subscribe": "Абониране за тема",
@@ -59,27 +59,27 @@
"notifications_actions_open_url_title": "Към {{url}}", "notifications_actions_open_url_title": "Към {{url}}",
"notifications_click_copy_url_button": "Копиране на препратка", "notifications_click_copy_url_button": "Копиране на препратка",
"notifications_click_open_button": "Отваряне", "notifications_click_open_button": "Отваряне",
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет", "notifications_click_copy_url_title": "Копира препратката в междинната памет",
"notifications_none_for_topic_title": "Липсват известия в темата", "notifications_none_for_topic_title": "Липсват известия в темата",
"notifications_none_for_any_title": "Липсват известия", "notifications_none_for_any_title": "Липсват известия",
"notifications_none_for_topic_description": "За да изпратите известия в тази тема, просто направете PUT или POST към адреса ѝ.", "notifications_none_for_topic_description": "За да изпратите известия в тази тема, просто изпратете PUT или POST към адреса ѝ.",
"notifications_none_for_any_description": "За да изпратите известия в тема, просто направете PUT или POST към адреса ѝ. Ето пример с една от вашите теми.", "notifications_none_for_any_description": "За да изпратите известия в тема, просто изпратете PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като изпратите съобщения чрез метода PUT или POST ще ги получите тук.", "notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като изпратите съобщения чрез метода PUT или POST ще ги получавате тук.",
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.", "notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
"publish_dialog_priority_min": "Мин. приоритет", "publish_dialog_priority_min": "Мин. приоритет",
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл", "publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}}",
"publish_dialog_base_url_label": "Адрес на услугата", "publish_dialog_base_url_label": "Адрес на услугата",
"publish_dialog_base_url_placeholder": "Адрес на услугата, напр. https://example.com", "publish_dialog_base_url_placeholder": "Адрес на услугата, напр. https://example.com",
"publish_dialog_topic_placeholder": "Име на темата, напр. phils_alerts", "publish_dialog_topic_placeholder": "Име на темата, напр. phils_alerts",
"publish_dialog_priority_low": "Нисък приоритет", "publish_dialog_priority_low": "Нисък приоритет",
"publish_dialog_attachment_limits_quota_reached": "надвишава квотата, остават {{remainingBytes}}", "publish_dialog_attachment_limits_quota_reached": "надвишава ограничението, оставащи {{remainingBytes}}",
"publish_dialog_priority_high": "Висок приоритет", "publish_dialog_priority_high": "Висок приоритет",
"publish_dialog_priority_default": "Подразбиран приоритет", "publish_dialog_priority_default": "Подразбиран приоритет",
"publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска", "publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска",
"publish_dialog_tags_label": "Етикети", "publish_dialog_tags_label": "Етикети",
"publish_dialog_email_label": "Адрес на електронна поща", "publish_dialog_email_label": "Адрес на електронна поща",
"publish_dialog_priority_max": "Макс. приоритет", "publish_dialog_priority_max": "Макс. приоритет",
"publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. warning, srv1-backup", "publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. внимание, диск",
"publish_dialog_click_label": "Адрес", "publish_dialog_click_label": "Адрес",
"publish_dialog_topic_label": "Име на темата", "publish_dialog_topic_label": "Име на темата",
"publish_dialog_title_label": "Заглавие", "publish_dialog_title_label": "Заглавие",
@@ -130,14 +130,14 @@
"prefs_users_dialog_username_label": "Потребител, напр. phil", "prefs_users_dialog_username_label": "Потребител, напр. phil",
"prefs_users_dialog_button_add": "Добавяне", "prefs_users_dialog_button_add": "Добавяне",
"error_boundary_title": "О, не, ntfy се срина", "error_boundary_title": "О, не, ntfy се срина",
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.", "error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink>, или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Следа от стека", "error_boundary_stack_trace": "Следа от стека",
"error_boundary_gathering_info": "Събиране на допълнителна информация…", "error_boundary_gathering_info": "Събиране на допълнителна информация…",
"notifications_loading": "Зареждане на известия…", "notifications_loading": "Зареждане на известия…",
"error_boundary_button_copy_stack_trace": "Копиране на следата от стека", "error_boundary_button_copy_stack_trace": "Копиране на следата от стека",
"prefs_users_description": "Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.", "prefs_users_description": "Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.",
"prefs_notifications_sound_description_none": "Известията не са съпроводени със звук", "prefs_notifications_sound_description_none": "Известията не са съпроводени със звук",
"prefs_notifications_sound_description_some": "При пристигане известията са съпроводени от звука „{{sound}}“", "prefs_notifications_sound_description_some": "Известията са съпроводени със звука „{{sound}}“",
"prefs_notifications_delete_after_never_description": "Известията никога не се премахват автоматично", "prefs_notifications_delete_after_never_description": "Известията никога не се премахват автоматично",
"prefs_notifications_delete_after_three_hours_description": "Известията се премахват автоматично след три часа", "prefs_notifications_delete_after_three_hours_description": "Известията се премахват автоматично след три часа",
"priority_min": "минимален", "priority_min": "минимален",
@@ -149,43 +149,8 @@
"prefs_notifications_delete_after_one_day_description": "Известията се премахват автоматично след един ден", "prefs_notifications_delete_after_one_day_description": "Известията се премахват автоматично след един ден",
"prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)", "prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)",
"prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец", "prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец",
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета", "prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета им",
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок", "prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок",
"notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}", "notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}",
"notifications_actions_not_supported": "Действието не се поддържа от приложението за интернет", "notifications_actions_not_supported": "Действието не се поддържа от приложението за уеб"
"action_bar_show_menu": "Показване на менюто",
"action_bar_logo_alt": "Логотип на ntfy",
"action_bar_toggle_mute": "Заглушаване или пускне на известията",
"action_bar_toggle_action_menu": "Отваряне или затваряне на менюто с действията",
"nav_button_muted": "Известията са заглушени",
"notifications_list": "Списък с известия",
"notifications_list_item": "Известие",
"notifications_delete": "Изтриване",
"notifications_mark_read": "Отбелязване като прочетено",
"nav_button_connecting": "свързване",
"message_bar_show_dialog": "Показване на диалога за публикуване",
"message_bar_publish": "Публикуване на съобщение",
"notifications_priority_x": "Приоритет {{priority}}",
"notifications_new_indicator": "Ново известие",
"notifications_attachment_image": "Прикачено изображение",
"notifications_attachment_file_image": "файл на изображение",
"notifications_attachment_file_video": "файл на видео",
"notifications_attachment_file_audio": "файл на аудио",
"notifications_attachment_file_app": "Инсталационен файл на приложение за Android",
"notifications_attachment_file_document": "друг документ",
"publish_dialog_emoji_picker_show": "Избор на емоция",
"publish_dialog_topic_reset": "Нулиране на тема",
"publish_dialog_click_reset": "Премахване на адрес",
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
"emoji_picker_search_clear": "Изчистване на търсенето",
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
"prefs_notifications_sound_play": "Възпроизвеждане на избрания звук",
"publish_dialog_attach_reset": "Премахване на адреса на файла за прикачане",
"prefs_users_delete_button": "Премахване на потребител",
"prefs_users_table": "Таблица с потребители",
"prefs_users_edit_button": "Промяна на потребител",
"error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа",
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>."
} }

View File

@@ -152,40 +152,5 @@
"prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.", "prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.",
"error_boundary_gathering_info": "Získejte více informací …", "error_boundary_gathering_info": "Získejte více informací …",
"prefs_appearance_language_title": "Jazyk", "prefs_appearance_language_title": "Jazyk",
"prefs_appearance_title": "Vzhled", "prefs_appearance_title": "Vzhled"
"action_bar_show_menu": "Zobrazit nabídku",
"action_bar_logo_alt": "logo ntfy",
"action_bar_toggle_mute": "Ztlumení/zrušení ztlumení oznámení",
"action_bar_toggle_action_menu": "Otevřít/zavřít nabídku akcí",
"message_bar_show_dialog": "Zobrazit okno pro odesílání oznámení",
"message_bar_publish": "Odeslat zprávu",
"nav_button_muted": "Oznámení ztlumena",
"nav_button_connecting": "připojování",
"notifications_list": "Seznam oznámení",
"notifications_list_item": "Oznámení",
"notifications_mark_read": "Označit jako přečtené",
"notifications_delete": "Smazat",
"notifications_new_indicator": "Nové oznámení",
"notifications_attachment_image": "Obrázek přílohy",
"notifications_attachment_file_image": "soubor s obrázkem",
"notifications_attachment_file_video": "video soubor",
"notifications_attachment_file_audio": "zvukový soubor",
"notifications_attachment_file_app": "Soubor s aplikací pro Android",
"publish_dialog_emoji_picker_show": "Vybrat emoji",
"publish_dialog_topic_reset": "Obnovení tématu",
"publish_dialog_click_reset": "Odebrat URL kliknutím",
"publish_dialog_email_reset": "Odebrat přeposlání e-mailu",
"publish_dialog_attach_reset": "Odebrat URL přílohy",
"publish_dialog_attached_file_remove": "Odebrat přiložený soubor",
"emoji_picker_search_clear": "Vyčistit vyhledávání",
"prefs_users_edit_button": "Upravit uživatele",
"prefs_users_delete_button": "Odstranit uživatele",
"error_boundary_unsupported_indexeddb_title": "Soukromé prohlížení není podporováno",
"error_boundary_unsupported_indexeddb_description": "Webová aplikace ntfy potřebuje ke svému fungování databázi IndexedDB a váš prohlížeč v režimu soukromého prohlížení databázi IndexedDB nepodporuje.<br/><br/>To je sice nepříjemné, ale používat webovou aplikaci ntfy v režimu soukromého prohlížení stejně nemá smysl, protože vše je uloženo v úložišti prohlížeče. Více se o tom můžete dočíst <githubLink>v tomto tématu na GitHubu</githubLink>, nebo se na nás obrátit pomocí služeb <discordLink>Discord</discordLink> nebo <matrixLink>Matrix</matrixLink>.",
"notifications_priority_x": "Priorita {{priority}}",
"subscribe_dialog_subscribe_base_url_label": "URL služby",
"prefs_notifications_sound_play": "Přehrát vybraný zvuk",
"prefs_users_table": "Tabulka uživatelů",
"notifications_attachment_file_document": "jiný dokument",
"publish_dialog_delay_reset": "Odebrat odložené doručení"
} }

View File

@@ -152,40 +152,5 @@
"prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht", "prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht",
"priority_min": "min", "priority_min": "min",
"notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt", "notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt",
"notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}", "notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}"
"action_bar_show_menu": "Menü anzeigen",
"action_bar_toggle_mute": "Stummschaltung der Benachrichtigungen an/aus",
"message_bar_show_dialog": "Dialog zur Veröffentlichung anzeigen",
"message_bar_publish": "Benachrichtigung veröffentlichen",
"nav_button_connecting": "verbinde",
"notifications_list": "Benachrichtigungsliste",
"notifications_mark_read": "Als gelesen markieren",
"notifications_delete": "Löschen",
"notifications_priority_x": "Priorität {{priority}}",
"notifications_attachment_file_image": "Bilddatei",
"notifications_attachment_image": "Bild des Anhangs",
"notifications_attachment_file_video": "Videodatei",
"notifications_attachment_file_audio": "Audiodatei",
"notifications_attachment_file_app": "Android App-Datei",
"notifications_attachment_file_document": "anderes Dokument",
"publish_dialog_attached_file_remove": "Angehängte Datei entfernen",
"emoji_picker_search_clear": "Suche leeren",
"subscribe_dialog_subscribe_base_url_label": "Service URL",
"prefs_notifications_sound_play": "Gewählten Sound abspielen",
"prefs_users_table": "Benutzertabelle",
"prefs_users_edit_button": "Benutzer bearbeiten",
"prefs_users_delete_button": "Benutzer löschen",
"error_boundary_unsupported_indexeddb_title": "Private Browser-Tabs werden nicht unterstützt",
"publish_dialog_delay_reset": "Verzögerte Zustellung entfernen",
"error_boundary_unsupported_indexeddb_description": "Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.<br/><br/>Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es <githubLink>in diesem GitHub-Issue</githubLink>, oder im Chat bei <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
"action_bar_toggle_action_menu": "Aktionsmenü öffnen/schließen",
"notifications_new_indicator": "Neue Benachrichtigung",
"publish_dialog_email_reset": "Email-Weiterleitung entfernen",
"action_bar_logo_alt": "ntfy Logo",
"nav_button_muted": "Benachrichtigungen stummgeschaltet",
"notifications_list_item": "Benachrichtigung",
"publish_dialog_emoji_picker_show": "Emoji wählen",
"publish_dialog_topic_reset": "Thema zurücksetzen",
"publish_dialog_attach_reset": "angehängte URL entfernen",
"publish_dialog_click_reset": "Klick-URL entfernen"
} }

View File

@@ -26,8 +26,7 @@
"alert_not_supported_description": "Notifications are not supported in your browser.", "alert_not_supported_description": "Notifications are not supported in your browser.",
"notifications_list": "Notifications list", "notifications_list": "Notifications list",
"notifications_list_item": "Notification", "notifications_list_item": "Notification",
"notifications_mark_read": "Mark as read", "notifications_delete": "Delete notification",
"notifications_delete": "Delete",
"notifications_copied_to_clipboard": "Copied to clipboard", "notifications_copied_to_clipboard": "Copied to clipboard",
"notifications_tags": "Tags", "notifications_tags": "Tags",
"notifications_priority_x": "Priority {{priority}}", "notifications_priority_x": "Priority {{priority}}",

View File

@@ -152,40 +152,5 @@
"prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana", "prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana",
"priority_low": "baja", "priority_low": "baja",
"notifications_actions_not_supported": "Acción no soportada en la aplicación web", "notifications_actions_not_supported": "Acción no soportada en la aplicación web",
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}", "notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}"
"error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada. <br/> <br/> Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"action_bar_show_menu": "Mostrar menú",
"action_bar_logo_alt": "logo de ntfy",
"action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción",
"message_bar_show_dialog": "Mostrar diálogo de publicación",
"message_bar_publish": "Publicar mensaje",
"nav_button_muted": "Notificaciones silenciadas",
"nav_button_connecting": "conectando",
"notifications_list": "Lista de notificaciones",
"notifications_list_item": "Notificación",
"notifications_mark_read": "Marcar como leído",
"notifications_delete": "Eliminar",
"notifications_priority_x": "Prioridad {{priority}}",
"notifications_new_indicator": "Nueva notificación",
"notifications_attachment_image": "Imagen adjunta",
"notifications_attachment_file_image": "archivo de imagen",
"notifications_attachment_file_video": "archivo de video",
"notifications_attachment_file_audio": "archivo de audio",
"notifications_attachment_file_app": "Archivo de aplicación de Android",
"notifications_attachment_file_document": "otro documento",
"action_bar_toggle_mute": "Silenciar/reactivar notificaciones",
"publish_dialog_emoji_picker_show": "Elige un emoji",
"publish_dialog_topic_reset": "Restablecer tópico",
"publish_dialog_click_reset": "Eliminar URL de clic",
"publish_dialog_email_reset": "Eliminar el reenvío de correo electrónico",
"publish_dialog_attach_reset": "Eliminar la URL del archivo adjunto",
"publish_dialog_delay_reset": "Eliminar entrega retrasada",
"publish_dialog_attached_file_remove": "Eliminar el archivo adjunto",
"emoji_picker_search_clear": "Limpiar búsqueda",
"subscribe_dialog_subscribe_base_url_label": "URL del servicio",
"prefs_notifications_sound_play": "Reproducir el sonido seleccionado",
"prefs_users_table": "Tabla de usuarios",
"prefs_users_edit_button": "Editar usuario",
"prefs_users_delete_button": "Eliminar usuario",
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada"
} }

View File

@@ -1,156 +0,0 @@
{
"action_bar_send_test_notification": "Teszt értesítés küldése",
"action_bar_clear_notifications": "Összes értesítés törlése",
"alert_not_supported_description": "A böngésző nem támogatja az értesítések fogadását.",
"action_bar_settings": "Beállítások",
"action_bar_unsubscribe": "Leiratkozás",
"message_bar_type_message": "Írd ide az üzenetet",
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
"nav_button_all_notifications": "Összes értesítés",
"nav_topics_title": "Feliratkozott témák",
"alert_grant_title": "Az értesítések le vannak tiltva",
"alert_grant_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.",
"nav_button_settings": "Beállítások",
"nav_button_documentation": "Dokumentáció",
"nav_button_publish_message": "Értesítés küldése",
"alert_grant_button": "Engedélyezés",
"alert_not_supported_title": "Nem támogatott funkció",
"notifications_copied_to_clipboard": "Másolva a vágólapra",
"notifications_tags": "Címkék",
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
"notifications_attachment_copy_url_button": "URL másolása",
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
"notifications_attachment_open_button": "Csatolmány megnyitása",
"notifications_attachment_link_expired": "A letöltési hivatkozás lejárt",
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
"nav_button_subscribe": "Feliratkozás témára",
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
"publish_dialog_message_published": "Értesítés elküldve",
"notifications_example": "Példa",
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.",
"publish_dialog_priority_low": "Alacsony prioritás",
"publish_dialog_priority_default": "Közepes prioritás",
"publish_dialog_priority_high": "Magas prioritás",
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
"publish_dialog_title_no_topic": "Értesítés küldése",
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
"publish_dialog_priority_min": "Legkisebb prioritás",
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
"publish_dialog_topic_label": "Téma neve",
"publish_dialog_priority_max": "Legmagasabb prioritás",
"publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
"publish_dialog_title_label": "Cím",
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
"publish_dialog_message_label": "Üzenet",
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
"publish_dialog_tags_label": "Címkék",
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
"publish_dialog_priority_label": "Prioritás",
"publish_dialog_click_label": "URL",
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com",
"publish_dialog_attach_label": "Csatolmány URL-e",
"publish_dialog_filename_label": "Fájlnév",
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
"publish_dialog_delay_label": "Késleltetés",
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
"publish_dialog_other_features": "Egyéb lehetőségek:",
"publish_dialog_chip_click_label": "Kattintási URL",
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
"publish_dialog_button_cancel_sending": "Küldés megállítása",
"publish_dialog_button_cancel": "Mégsem",
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
"publish_dialog_attached_file_title": "Csatolt fájl:",
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
"emoji_picker_search_placeholder": "Emoji keresése",
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
"subscribe_dialog_login_title": "Be kell jelentkezni",
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
"subscribe_dialog_login_password_label": "Jelszó",
"subscribe_dialog_login_button_back": "Vissza",
"subscribe_dialog_login_button_login": "Belépés",
"subscribe_dialog_error_user_anonymous": "névtelen",
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg",
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
"prefs_notifications_sound_title": "Értesítés hangja",
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
"prefs_notifications_sound_no_sound": "Hang nélkül",
"prefs_notifications_delete_after_one_week": "1 hét után",
"prefs_notifications_delete_after_one_month": "1 hónap után",
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
"prefs_users_table_user_header": "Felhasználó",
"prefs_users_table_base_url_header": "Szerver címe",
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
"prefs_users_dialog_password_label": "Jelszó",
"prefs_users_dialog_button_add": "Hozzáadás",
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
"notifications_loading": "Értesítések betöltése …",
"publish_dialog_progress_uploading": "Feltöltés …",
"notifications_click_copy_url_button": "Hivatkozás másolása",
"notifications_click_open_button": "Hivatkozás megnyitása",
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …",
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
"prefs_notifications_delete_after_one_day": "1 nap után",
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
"publish_dialog_chip_email_label": "Továbbítás email-ben",
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
"publish_dialog_button_send": "Küldés",
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
"prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás",
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
"prefs_users_add_button": "Felhasználó hozzáadása",
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
"prefs_notifications_title": "Értesítések",
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
"prefs_notifications_delete_after_three_hours": "3 óra után",
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
"prefs_notifications_delete_after_never": "Soha",
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
"prefs_appearance_title": "Megjelenés",
"priority_default": "közepes",
"priority_high": "magas",
"priority_max": "legmagasabb",
"priority_min": "legkisebb",
"error_boundary_gathering_info": "Több információ…",
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
"prefs_users_title": "Felhasználók kezelése",
"prefs_users_dialog_button_cancel": "Mégsem",
"prefs_users_dialog_button_save": "Mentés",
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
"prefs_appearance_language_title": "Nyelv",
"priority_low": "alacsony",
"error_boundary_stack_trace": "Verem nyomkövetés",
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>."
}

View File

@@ -152,40 +152,5 @@
"priority_default": "bawaan", "priority_default": "bawaan",
"priority_min": "min", "priority_min": "min",
"notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web", "notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web",
"notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}", "notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}"
"action_bar_show_menu": "Tampilkan menu",
"action_bar_logo_alt": "logo ntfy",
"action_bar_toggle_mute": "Bisu/suarakan notifikasi",
"action_bar_toggle_action_menu": "Buka/tutup menu tindakan",
"message_bar_show_dialog": "Tampilkan dialog publikasi",
"message_bar_publish": "Publikasikan pesan",
"nav_button_muted": "Notifikasi dibisukan",
"nav_button_connecting": "menghubungkan",
"notifications_list": "Daftar notifikasi",
"notifications_list_item": "Notifikasi",
"notifications_mark_read": "Tandai sebagai dibaca",
"notifications_delete": "Hapus",
"notifications_priority_x": "Prioritas {{priority}}",
"notifications_new_indicator": "Notifikasi baru",
"notifications_attachment_image": "Lampiran gambar",
"notifications_attachment_file_image": "file gambar",
"notifications_attachment_file_video": "file",
"notifications_attachment_file_audio": "file audio",
"notifications_attachment_file_app": "file aplikasi Android",
"notifications_attachment_file_document": "dokumen lainnya",
"publish_dialog_emoji_picker_show": "Pilih emoji",
"publish_dialog_topic_reset": "Atur ulang topik",
"publish_dialog_click_reset": "Hapus URL klik",
"publish_dialog_email_reset": "Hapus terusan email",
"publish_dialog_attach_reset": "Hapus URL lampiran",
"publish_dialog_delay_reset": "Hapus pengiriman telat",
"publish_dialog_attached_file_remove": "Hapus file yang dilampirkan",
"emoji_picker_search_clear": "Hapus pencarian",
"subscribe_dialog_subscribe_base_url_label": "URL layanan",
"prefs_notifications_sound_play": "Mainkan suara yang dipilih",
"prefs_users_table": "Tabel pengguna",
"prefs_users_edit_button": "Edit pengguna",
"prefs_users_delete_button": "Hapus pengguna",
"error_boundary_unsupported_indexeddb_description": "Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.<br/><br/>Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya <githubLink>di masalah GitHub ini</githubLink>, atau berbicara dengan kami di <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
"error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung"
} }

View File

@@ -1,191 +0,0 @@
{
"action_bar_logo_alt": "logo ntfy",
"action_bar_settings": "Impostazioni",
"action_bar_clear_notifications": "Cancella tutte le notifiche",
"action_bar_unsubscribe": "Annulla l'iscrizione",
"action_bar_toggle_action_menu": "Apri/chiudi il menu delle azioni",
"message_bar_type_message": "Digita un messaggio qui",
"message_bar_error_publishing": "Errore durante la pubblicazione della notifica",
"message_bar_show_dialog": "Mostra la finestra di dialogo di pubblicazione",
"message_bar_publish": "Pubblica messaggio",
"nav_topics_title": "Topic a cui si è iscritti",
"nav_button_all_notifications": "Tutte le notifiche",
"nav_button_settings": "Impostazioni",
"nav_button_publish_message": "Pubblica notifica",
"nav_button_subscribe": "Iscriviti al topic",
"nav_button_muted": "Notifiche disattivate",
"nav_button_connecting": "connessione",
"alert_grant_title": "Le notifiche sono disabilitate",
"alert_grant_button": "Concedi ora",
"notifications_list": "Elenco notifiche",
"notifications_list_item": "Notifiche",
"notifications_mark_read": "Segna come letto",
"notifications_delete": "Elimina",
"notifications_copied_to_clipboard": "Copiato negli appunti",
"notifications_tags": "Tags",
"notifications_priority_x": "Priorità {{priority}}",
"notifications_new_indicator": "Nuova notifica",
"notifications_attachment_image": "Immagine allegata",
"notifications_attachment_copy_url_title": "Copia l'URL dell'allegato negli appunti",
"notifications_attachment_copy_url_button": "Copia URL",
"notifications_attachment_open_title": "Vai a {{url}}",
"notifications_attachment_open_button": "Apri allegato",
"notifications_attachment_link_expires": "Il collegamento scade il {{date}}",
"notifications_attachment_link_expired": "link per il download scaduto",
"notifications_attachment_file_image": "file immagine",
"notifications_attachment_file_video": "file video",
"action_bar_toggle_mute": "Abilita/disabilita le notifiche",
"notifications_attachment_file_document": "altro documento",
"notifications_click_copy_url_button": "Copia link",
"notifications_click_open_button": "Apri link",
"notifications_actions_open_url_title": "Vai a {{url}}",
"notifications_actions_not_supported": "Azione non supportata nell'app Web",
"notifications_none_for_topic_title": "Non hai ancora ricevuto alcuna notifica per questo topic.",
"notifications_none_for_topic_description": "Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL del topic.",
"notifications_none_for_any_title": "Non hai ricevuto alcuna notifica.",
"notifications_no_subscriptions_title": "Sembra che tu non abbia ancora abbonamenti.",
"notifications_example": "Esempio",
"notifications_more_details": "Per ulteriori informazioni, consulta il <websiteLink>sito web</websiteLink> o <docsLink>documentazione</docsLink>.",
"notifications_loading": "Caricamento notifiche in corso…",
"publish_dialog_title_topic": "Pubblica su {{topic}}",
"publish_dialog_title_no_topic": "Pubblica notifica",
"publish_dialog_progress_uploading": "Caricamento in corso…",
"publish_dialog_progress_uploading_detail": "Caricamento {{loaded}}/{{total}} ({{percent}}%)…",
"publish_dialog_message_published": "Notifica pubblicata",
"publish_dialog_attachment_limits_file_and_quota_reached": "supera {{fileSizeLimit}} limite di file e quota, {{remainingBytes}} rimanenti",
"publish_dialog_attachment_limits_file_reached": "supera di {{fileSizeLimit}} il limite dei file",
"publish_dialog_attachment_limits_quota_reached": "supera la quota, {{remainingBytes}} rimanenti",
"publish_dialog_emoji_picker_show": "Scegli emoji",
"publish_dialog_priority_min": "Min. priorità",
"publish_dialog_priority_low": "Bassa priorità",
"publish_dialog_priority_default": "Priorità predefinita",
"publish_dialog_priority_high": "Priorità alta",
"publish_dialog_priority_max": "Max. priorità",
"publish_dialog_base_url_label": "URL del servizio",
"publish_dialog_base_url_placeholder": "URL del servizio, ad es. https://esempio.com",
"publish_dialog_topic_label": "Nome topic",
"publish_dialog_topic_placeholder": "Nome topic, ad es. avvisi_di_phil",
"publish_dialog_topic_reset": "Reset topic",
"publish_dialog_title_label": "Titolo",
"publish_dialog_title_placeholder": "Titolo della notifica, ad es. Avviso di spazio su disco",
"publish_dialog_message_label": "Messaggio",
"publish_dialog_message_placeholder": "Digita un messaggio qui",
"publish_dialog_tags_label": "Tags",
"publish_dialog_priority_label": "Priorità",
"publish_dialog_click_label": "Clicca URL",
"publish_dialog_click_reset": "Rimuovi l'URL del clic",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Indirizzo a cui inoltrare la notifica, ad es. phil@example.com",
"publish_dialog_email_reset": "Rimuovi inoltro email",
"publish_dialog_attach_label": "URL Allegato",
"publish_dialog_attach_reset": "Rimuovi l'URL dell'allegato",
"publish_dialog_filename_label": "Nome del file",
"publish_dialog_filename_placeholder": "Nome file allegato",
"publish_dialog_delay_placeholder": "Consegna ritardata, ad es. {{unixTimestamp}}, {{relativeTime}} o \"{{naturalLanguage}}\" (solo in inglese)",
"publish_dialog_delay_reset": "Rimuovere la consegna ritardata",
"publish_dialog_other_features": "Altre funzionalità:",
"publish_dialog_chip_click_label": "Fare clic su URL",
"publish_dialog_chip_email_label": "Inoltra a e-mail",
"publish_dialog_chip_attach_url_label": "Allega il file tramite URL",
"publish_dialog_chip_attach_file_label": "Allega file locale",
"publish_dialog_chip_delay_label": "Ritardo nella consegna",
"publish_dialog_button_cancel_sending": "Annulla l'invio",
"publish_dialog_button_cancel": "Annulla",
"publish_dialog_button_send": "Invia",
"publish_dialog_checkbox_publish_another": "Pubblica un altro",
"publish_dialog_attached_file_title": "File allegato:",
"publish_dialog_attached_file_remove": "Rimuovi il file allegato",
"publish_dialog_drop_file_here": "Trascina il file qui",
"emoji_picker_search_clear": "Cancella ricerca",
"subscribe_dialog_subscribe_title": "Iscriviti al topic",
"subscribe_dialog_subscribe_topic_placeholder": "Nome dell'argomento, ad es. avvisi_di_phil",
"subscribe_dialog_subscribe_base_url_label": "URL del servizio",
"subscribe_dialog_subscribe_button_cancel": "Annulla",
"subscribe_dialog_login_title": "Accesso richiesto",
"subscribe_dialog_login_username_label": "Nome utente, ad es. phil",
"subscribe_dialog_login_button_login": "Login",
"subscribe_dialog_error_user_anonymous": "anonimo",
"prefs_notifications_sound_title": "Suono di notifica",
"prefs_notifications_sound_description_some": "Le notifiche riproducono il suono {{sound}} quando arrivano",
"prefs_notifications_sound_no_sound": "Nessun suono",
"prefs_notifications_min_priority_description_any": "Visualizzazione di tutte le notifiche, indipendentemente dalla priorità",
"prefs_notifications_min_priority_description_max": "Mostra notifiche se la priorità è 5 (max)",
"prefs_notifications_min_priority_any": "Qualsiasi priorità",
"prefs_notifications_min_priority_low_and_higher": "Priorità bassa e superiore",
"prefs_notifications_min_priority_high_and_higher": "Priorità alta e superiore",
"prefs_notifications_min_priority_max_only": "Solo priorità massima",
"prefs_notifications_delete_after_never": "Mai",
"prefs_notifications_delete_after_three_hours": "Dopo tre ore",
"prefs_notifications_delete_after_one_day": "Dopo un giorno",
"prefs_notifications_delete_after_never_description": "Le notifiche non vengono mai eliminate automaticamente",
"prefs_notifications_delete_after_one_day_description": "Le notifiche vengono eliminate automaticamente dopo un giorno",
"prefs_notifications_delete_after_one_week_description": "Le notifiche vengono eliminate automaticamente dopo una settimana",
"prefs_notifications_delete_after_one_month_description": "Le notifiche vengono eliminate automaticamente dopo un mese",
"prefs_users_title": "Gestisci gli utenti",
"prefs_users_description": "Aggiungi/rimuovi utenti per i tuoi topic protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.",
"prefs_users_table": "Tabella utenti",
"prefs_users_add_button": "Aggiungi utente",
"prefs_users_edit_button": "Modifica utente",
"prefs_users_delete_button": "Elimina utente",
"prefs_users_table_user_header": "Utente",
"prefs_users_table_base_url_header": "URL del servizio",
"prefs_users_dialog_title_add": "Aggiungi utente",
"prefs_users_dialog_title_edit": "Modifica utente",
"prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh",
"prefs_users_dialog_username_label": "Nome utente, ad es. phil",
"prefs_users_dialog_password_label": "Password",
"prefs_users_dialog_button_cancel": "Annulla",
"prefs_users_dialog_button_add": "Aggiungere",
"prefs_users_dialog_button_save": "Salva",
"prefs_appearance_title": "Aspetto",
"prefs_appearance_language_title": "Lingua",
"priority_min": "min",
"priority_low": "basso",
"priority_default": "predefinito",
"priority_high": "alto",
"priority_max": "max",
"error_boundary_title": "Oh no, ntfy è andato in crash",
"error_boundary_description": "Questo ovviamente non dovrebbe accadere. Mi dispiace molto per questo.<br/>Se hai un minuto, per favore <githubLink>segnala su GitHub</githubLink>, o faccelo sapere tramite <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink> .",
"error_boundary_button_copy_stack_trace": "Copia traccia dello stack",
"error_boundary_stack_trace": "Traccia dello stack",
"error_boundary_gathering_info": "Raccogli più informazioni…",
"error_boundary_unsupported_indexeddb_title": "Navigazione privata non supportata",
"action_bar_show_menu": "Mostra menu",
"action_bar_send_test_notification": "Inviare una notifica di prova",
"alert_not_supported_description": "Le notifiche non sono supportate nel tuo browser.",
"nav_button_documentation": "Documentazione",
"notifications_actions_http_request_title": "Invia HTTP {{method}} a {{url}}",
"alert_grant_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop.",
"alert_not_supported_title": "Notifiche non supportate",
"notifications_attachment_file_app": "file app Android",
"notifications_no_subscriptions_description": "Fai clic sul link \"{{linktext}}\" per creare o iscriverti a un topic. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.",
"notifications_attachment_file_audio": "file audio",
"notifications_none_for_any_description": "Per inviare notifiche a un topic, è sufficiente PUT o POST all'URL del topic. Ecco un esempio utilizzando uno dei tuoi topic.",
"notifications_click_copy_url_title": "Copia l'URL del collegamento negli appunti",
"prefs_notifications_sound_description_none": "Le notifiche non emettono alcun suono quando arrivano",
"publish_dialog_delay_label": "Ritardo",
"publish_dialog_tags_placeholder": "Elenco di tag separato da virgole, ad es. avviso, backup-srv1",
"publish_dialog_click_placeholder": "URL che viene aperto quando si fa clic sulla notifica",
"publish_dialog_attach_placeholder": "Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk",
"publish_dialog_chip_topic_label": "Cambia topic",
"publish_dialog_details_examples_description": "Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla <docsLink>documentazione</docsLink>.",
"publish_dialog_attached_file_filename_placeholder": "Nome file allegato",
"emoji_picker_search_placeholder": "Cerca emoji",
"subscribe_dialog_subscribe_description": "Gli argomenti potrebbero non essere protetti da password, quindi scegli un nome che non sia facile da indovinare. Una volta iscritto, puoi inviare le notifiche tramite PUT/POST.",
"subscribe_dialog_subscribe_use_another_label": "Usa un altro server",
"subscribe_dialog_login_password_label": "Password",
"subscribe_dialog_subscribe_button_subscribe": "Iscriviti",
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
"prefs_notifications_min_priority_title": "Priorità minima",
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
"subscribe_dialog_login_button_back": "Indietro",
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
"prefs_notifications_title": "Notifiche",
"prefs_notifications_delete_after_title": "Elimina le notifiche",
"prefs_notifications_min_priority_default_and_higher": "Priorità predefinita e superiore",
"prefs_notifications_min_priority_description_x_or_higher": "Mostra le notifiche se la priorità è {{number}} ({{name}}) o superiore",
"prefs_notifications_delete_after_one_week": "Dopo una settimana",
"prefs_notifications_delete_after_one_month": "Dopo un mese",
"prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore",
"error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>."
}

View File

@@ -49,7 +49,7 @@
"publish_dialog_message_label": "メッセージ", "publish_dialog_message_label": "メッセージ",
"publish_dialog_email_label": "メール", "publish_dialog_email_label": "メール",
"notifications_none_for_any_title": "まだ通知を受信していません。", "notifications_none_for_any_title": "まだ通知を受信していません。",
"publish_dialog_priority_max": "優先度 最高", "publish_dialog_priority_max": "優先度最高",
"publish_dialog_button_cancel_sending": "送信をキャンセル", "publish_dialog_button_cancel_sending": "送信をキャンセル",
"publish_dialog_attach_label": "添付URL", "publish_dialog_attach_label": "添付URL",
"notifications_none_for_any_description": "トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。", "notifications_none_for_any_description": "トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。",
@@ -60,14 +60,14 @@
"publish_dialog_email_placeholder": "通知を転送するアドレス, 例) phil@example.com", "publish_dialog_email_placeholder": "通知を転送するアドレス, 例) phil@example.com",
"notifications_more_details": "詳しい情報は、<websiteLink>ウェブサイト</websiteLink> または <docsLink>ドキュメント</docsLink> を参照してください。", "notifications_more_details": "詳しい情報は、<websiteLink>ウェブサイト</websiteLink> または <docsLink>ドキュメント</docsLink> を参照してください。",
"publish_dialog_attachment_limits_file_reached": "ファイルサイズ制限 {{fileSizeLimit}} を超えました", "publish_dialog_attachment_limits_file_reached": "ファイルサイズ制限 {{fileSizeLimit}} を超えました",
"publish_dialog_priority_min": "優先度 最低", "publish_dialog_priority_min": "優先度最低",
"publish_dialog_priority_low": "優先度 低", "publish_dialog_priority_low": "優先度低",
"publish_dialog_priority_default": "優先度 通常", "publish_dialog_priority_default": "優先度通常",
"publish_dialog_base_url_label": "サービスURL", "publish_dialog_base_url_label": "サービスURL",
"publish_dialog_other_features": "他の機能:", "publish_dialog_other_features": "他の機能:",
"notifications_loading": "通知を読み込み中…", "notifications_loading": "通知を読み込み中…",
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}", "publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
"publish_dialog_priority_high": "優先度 高", "publish_dialog_priority_high": "優先度高",
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts", "publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告", "publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
"publish_dialog_message_placeholder": "メッセージ本文を入力してください", "publish_dialog_message_placeholder": "メッセージ本文を入力してください",
@@ -129,7 +129,7 @@
"prefs_users_table_base_url_header": "サービスURL", "prefs_users_table_base_url_header": "サービスURL",
"prefs_users_dialog_username_label": "ユーザー名, 例) phil", "prefs_users_dialog_username_label": "ユーザー名, 例) phil",
"prefs_users_dialog_password_label": "パスワード", "prefs_users_dialog_password_label": "パスワード",
"error_boundary_title": "おっと、ntfyがクラッシュしました", "error_boundary_title": "ああ、ntfyがクラッシュしました",
"error_boundary_button_copy_stack_trace": "スタックトレースをコピー", "error_boundary_button_copy_stack_trace": "スタックトレースをコピー",
"error_boundary_stack_trace": "スタックトレース", "error_boundary_stack_trace": "スタックトレース",
"error_boundary_gathering_info": "更に情報を集める…", "error_boundary_gathering_info": "更に情報を集める…",
@@ -150,42 +150,5 @@
"priority_default": "通常", "priority_default": "通常",
"prefs_notifications_delete_after_three_hours_description": "通知は3時間後に自動的に削除されます", "prefs_notifications_delete_after_three_hours_description": "通知は3時間後に自動的に削除されます",
"priority_low": "低", "priority_low": "低",
"priority_min": "最低", "priority_min": "最低"
"notifications_actions_not_supported": "このアクションはWebアプリではサポートされていません",
"notifications_actions_http_request_title": "{{url}}にHTTP {{method}}を送信",
"prefs_users_edit_button": "ユーザーを編集",
"publish_dialog_attached_file_remove": "添付ファイルを削除",
"error_boundary_unsupported_indexeddb_description": "nfty webアプリは動作にIndexedDBを使用しますが、あなたのブラウザはプライベートブラウジングモード時にIndexedDBをサポートしていません。<br/><br/>これは残念なことですが、ntfy webアプリは全ての情報をブラウザストレージに保存して動作するため、プライベートブラウジングモードで利用するのはあまり意味がないかも知れません。詳細については <githubLink>GitHub issue</githubLink>を参照するか、<discordLink>Discord</discordLink>や<matrixLink>Matrix</matrixLink>の議論に参加してください。",
"action_bar_show_menu": "メニューを表示",
"action_bar_logo_alt": "ntfyロゴ",
"action_bar_toggle_mute": "通知をミュート/解除",
"action_bar_toggle_action_menu": "動作メニューを開く/閉じる",
"message_bar_show_dialog": "送信ダイアログを表示",
"message_bar_publish": "メッセージを送信",
"nav_button_muted": "ミュートされた通知",
"nav_button_connecting": "接続中",
"notifications_list": "通知一覧",
"notifications_new_indicator": "新しい通知",
"notifications_list_item": "通知",
"notifications_mark_read": "既読にする",
"notifications_delete": "削除",
"notifications_priority_x": "優先度 {{priority}}",
"notifications_attachment_image": "添付画像",
"notifications_attachment_file_image": "画像ファイル",
"notifications_attachment_file_video": "動画ファイル",
"notifications_attachment_file_audio": "音声ファイル",
"notifications_attachment_file_app": "Androidアプリファイル",
"notifications_attachment_file_document": "その他文書",
"publish_dialog_emoji_picker_show": "絵文字",
"publish_dialog_topic_reset": "トピックをリセット",
"publish_dialog_click_reset": "クリックURLを削除",
"publish_dialog_email_reset": "メール転送を削除",
"publish_dialog_attach_reset": "添付URLを削除",
"publish_dialog_delay_reset": "配信遅延を削除",
"emoji_picker_search_clear": "検索をクリア",
"subscribe_dialog_subscribe_base_url_label": "サーバーURL",
"prefs_notifications_sound_play": "選択されたサウンドを再生",
"prefs_users_table": "ユーザー一覧",
"prefs_users_delete_button": "ユーザーを削除",
"error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません"
} }

View File

@@ -1,191 +1 @@
{ {}
"action_bar_settings": "Instellingen",
"action_bar_send_test_notification": "Stuur test notificatie",
"action_bar_clear_notifications": "Wis alle notificaties",
"message_bar_type_message": "Typ hier een bericht",
"action_bar_unsubscribe": "Afmelden",
"message_bar_error_publishing": "Fout bij publiceren notificatie",
"nav_topics_title": "Geabonneerde onderwerpen",
"nav_button_settings": "Instellingen",
"alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.",
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
"publish_dialog_tags_label": "Tags",
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
"prefs_users_dialog_title_edit": "Gebruiker bewerken",
"error_boundary_title": "Oh nee, ntfy is vastgelopen",
"error_boundary_description": "Dit hoort natuurlijk niet te gebeuren. Onze excuses.<br/>Wanneer het mogelijk is, <githubLink>meld deze fout op GitHub</githubLink>, of laat het ons weten via <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Stack trace kopiëren",
"error_boundary_stack_trace": "Stacktrace",
"error_boundary_gathering_info": "Meer informatie verzamelen …",
"prefs_users_delete_button": "Gebruiker verwijderen",
"prefs_notifications_delete_after_one_week": "Na één week",
"prefs_notifications_delete_after_one_month": "Na één maand",
"prefs_users_dialog_title_add": "Gebruiker toevoegen",
"prefs_users_dialog_password_label": "Wachtwoord",
"error_boundary_unsupported_indexeddb_description": "De ntfy web applicatie heeft IndexedDB nodig om correct te kunnen functioneren, helaas ondersteund jouw browser IndexedDB niet in privé / incognito modus.<br/><br/>Dit is jammer maar het is ook onlogisch om de ntfy web applicatie in privé / incognito modus te gebruiken want alle gegevens worden bewaard in de browser zijn lokale opslag. Je kan hier meer over lezen <githubLink>in deze GitHub issue</githubLink>, of praat met ons op <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.",
"action_bar_show_menu": "Toon menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_toggle_mute": "Notificaties dempen/opheffen",
"action_bar_toggle_action_menu": "Actie menu openen/sluiten",
"message_bar_show_dialog": "Toon publicatie venster",
"message_bar_publish": "Bericht publiceren",
"nav_button_all_notifications": "Alle notificaties",
"nav_button_documentation": "Documentatie",
"nav_button_publish_message": "Notificatie publiceren",
"nav_button_subscribe": "Onderwerp abonneren",
"nav_button_muted": "Notificaties gedempt",
"nav_button_connecting": "verbinden",
"alert_grant_title": "Notificaties zijn uitgeschakeld",
"alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.",
"alert_grant_button": "Nu toestaan",
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
"notifications_list": "Notificaties lijst",
"notifications_list_item": "Notificatie",
"notifications_mark_read": "Markeer als gelezen",
"notifications_delete": "Verwijder",
"notifications_copied_to_clipboard": "Gekopieerd naar klembord",
"notifications_tags": "Tags",
"notifications_priority_x": "Prioriteit {{priority}}",
"notifications_new_indicator": "Nieuwe notificatie",
"notifications_attachment_image": "Afbeelding bijlage",
"notifications_attachment_copy_url_title": "Kopieer URL van bijlage naar klembord",
"notifications_attachment_copy_url_button": "URL kopiëren",
"notifications_attachment_open_title": "Ga naar {{url}}",
"notifications_attachment_open_button": "Bijlage openen",
"notifications_attachment_link_expires": "link vervalt op {{date}}",
"notifications_attachment_link_expired": "download link is verlopen",
"notifications_attachment_file_image": "afbeeldingsbestand",
"notifications_attachment_file_video": "videobestand",
"notifications_attachment_file_audio": "audiobestand",
"notifications_attachment_file_app": "Android app bestand",
"notifications_attachment_file_document": "overig document",
"notifications_click_copy_url_title": "URL naar klembord kopiëren",
"notifications_click_copy_url_button": "Link kopiëren",
"notifications_click_open_button": "Link openen",
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL.",
"notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL. Hier is een voorbeeld met één van je onderwerpen.",
"notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.",
"notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.",
"notifications_example": "Voorbeeld",
"notifications_more_details": "Voor meer informatie, bezoek de <websiteLink>website</websiteLink> of <docsLink>documentatie</docsLink>.",
"notifications_loading": "Notificaties laden …",
"publish_dialog_title_topic": "Publiceren naar {{topic}}",
"publish_dialog_title_no_topic": "Notificatie publiceren",
"publish_dialog_progress_uploading": "Uploaden …",
"notifications_actions_open_url_title": "Ga naar {{url}}",
"notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie",
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
"publish_dialog_priority_low": "Lage prioriteit",
"publish_dialog_progress_uploading_detail": "Uploaden {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Notificatie gepubliceerd",
"publish_dialog_attachment_limits_file_and_quota_reached": "overschrijd {{fileSizeLimit}} bestandslimiet en quotum, {{remainingBytes}} resterend",
"publish_dialog_attachment_limits_file_reached": "overschrijd {{fileSizeLimit}} bestandslimiet",
"publish_dialog_priority_default": "Standaard prioriteit",
"publish_dialog_attachment_limits_quota_reached": "overschrijd quotum, {{remainingBytes}} resterend",
"publish_dialog_emoji_picker_show": "Kies een emoji",
"publish_dialog_priority_high": "Hoge prioriteit",
"publish_dialog_priority_max": "Maximale prioriteit",
"publish_dialog_priority_min": "Minimale prioriteit",
"publish_dialog_base_url_label": "Service URL",
"publish_dialog_base_url_placeholder": "Service URL, bijvoorbeeld: https://voorbeeld.com",
"publish_dialog_topic_label": "Onderwerp",
"publish_dialog_topic_placeholder": "Onderwerp, bijv. phil_alerts",
"publish_dialog_topic_reset": "Onderwerp resetten",
"publish_dialog_title_label": "Titel",
"publish_dialog_title_placeholder": "Notificatie titel , bijv. Schijfruimte alarm",
"publish_dialog_message_label": "Bericht",
"publish_dialog_message_placeholder": "Typ hier een bericht",
"publish_dialog_tags_placeholder": "Komma gescheiden lijst met tags, bijv. waarschuwing, srv1-backup",
"publish_dialog_priority_label": "Prioriteit",
"publish_dialog_click_label": "Klik URL",
"publish_dialog_click_reset": "Verwijder klik URL",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Adres om de notificatie naar door te sturen, bijv. phil@voorbeeld.com",
"publish_dialog_email_reset": "Email doorsturen verwijderen",
"publish_dialog_attach_label": "URL van bijlage",
"publish_dialog_click_placeholder": "URL die geopend zal worden wanneer op de notificatie geklikt wordt",
"publish_dialog_attach_placeholder": "Bestand bijvoegen via URL, bijv. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Bijlage URL verwijderen",
"publish_dialog_filename_label": "Bestandsnaam",
"publish_dialog_filename_placeholder": "Bestandsnaam van bijlage",
"publish_dialog_delay_label": "Uitstellen",
"publish_dialog_delay_placeholder": "Bezorging uitstellen, bijv. {{unixTimestamp}}, {{relativeTime}}, of \"{{naturalLanguage}}\" (alleen Engels)",
"publish_dialog_delay_reset": "Verwijder uitgestelde bezorging",
"publish_dialog_other_features": "Andere functionaliteiten:",
"publish_dialog_chip_click_label": "Klik URL",
"publish_dialog_chip_email_label": "Doorsturen naar email",
"publish_dialog_chip_attach_url_label": "Bestand bijvoegen via URL",
"publish_dialog_chip_delay_label": "Uitgestelde bezorging",
"publish_dialog_chip_topic_label": "Onderwerp veranderen",
"publish_dialog_details_examples_description": "Voor meer voorbeelden en gedetailleerde beschrijvingen van alle functionaliteiten, bekijk de <docsLink>documentatie</docsLink>.",
"publish_dialog_button_cancel_sending": "Versturen annuleren",
"publish_dialog_button_cancel": "Annuleer",
"publish_dialog_button_send": "Verstuur",
"publish_dialog_checkbox_publish_another": "Nog een bericht versturen",
"publish_dialog_attached_file_title": "Bijgevoegd bestand:",
"publish_dialog_attached_file_filename_placeholder": "Bijlage bestandsnaam",
"publish_dialog_attached_file_remove": "Verwijder bijgevoegd bestand",
"publish_dialog_drop_file_here": "Bestand hier slepen",
"emoji_picker_search_placeholder": "Emoji zoeken",
"emoji_picker_search_clear": "Zoeken leegmaken",
"subscribe_dialog_subscribe_topic_placeholder": "Onderwerp naam, bijv. phils_waarschuwingen",
"subscribe_dialog_subscribe_use_another_label": "Gebruik een andere server",
"subscribe_dialog_subscribe_base_url_label": "Service URL",
"subscribe_dialog_subscribe_button_cancel": "Annuleren",
"subscribe_dialog_subscribe_button_subscribe": "Abonneren",
"subscribe_dialog_login_title": "Aanmelding vereist",
"subscribe_dialog_login_description": "Dit onderwerp is beveiligd met een wachtwoord. Geef een gebruikersnaam en wachtwoord op om te abonneren.",
"subscribe_dialog_login_username_label": "Gebruikersnaam, bijv. phil",
"subscribe_dialog_subscribe_title": "Onderwerp abonneren",
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
"subscribe_dialog_login_password_label": "Wachtwoord",
"subscribe_dialog_login_button_back": "Terug",
"subscribe_dialog_login_button_login": "Aanmelden",
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
"subscribe_dialog_error_user_anonymous": "anoniem",
"prefs_notifications_title": "Notificaties",
"prefs_notifications_sound_title": "Meldingsgeluid",
"prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven",
"prefs_notifications_sound_play": "Geselecteerd geluid afspelen",
"prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} afspelen",
"prefs_notifications_sound_no_sound": "Geen geluid",
"prefs_notifications_min_priority_title": "Minimale prioriteit",
"prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit",
"prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit is {{number}} ({{name}}) of hoger",
"prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit is 5 (maximaal)",
"prefs_notifications_min_priority_any": "Elke prioriteit",
"prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger",
"prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger",
"prefs_notifications_min_priority_high_and_higher": "Hoge prioriteit en hoger",
"prefs_notifications_min_priority_max_only": "Alleen maximale prioriteit",
"prefs_notifications_delete_after_title": "Notificaties verwijderen",
"prefs_notifications_delete_after_never": "Nooit",
"prefs_notifications_delete_after_three_hours": "Na drie uur",
"prefs_notifications_delete_after_one_day": "Na één dag",
"prefs_notifications_delete_after_never_description": "Notificaties worden nooit automatisch verwijderd",
"prefs_notifications_delete_after_three_hours_description": "Notificaties worden na drie uur automatisch verwijderd",
"prefs_notifications_delete_after_one_day_description": "Notificaties worden na één dag automatisch verwijderd",
"prefs_notifications_delete_after_one_week_description": "Notificaties worden na één week automatisch verwijderd",
"prefs_notifications_delete_after_one_month_description": "Notificaties worden na één maand automatisch verwijderd",
"prefs_users_title": "Gebruikers beheren",
"prefs_users_description": "Gebruikers voor beveiligde onderwerpen kunnen hier toegevoegd of verwijderd worden. Let op: gebruikersnaam en wachtwoord worden opgeslagen in lokale browser opslag.",
"prefs_users_table": "Gebruikerstabel",
"prefs_users_add_button": "Gebruiker toevoegen",
"prefs_users_edit_button": "Gebruiker bewerken",
"prefs_users_table_user_header": "Gebruiker",
"prefs_users_table_base_url_header": "Service URL",
"prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh",
"prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil",
"prefs_users_dialog_button_cancel": "Annuleren",
"prefs_users_dialog_button_add": "Toevoegen",
"prefs_users_dialog_button_save": "Bewaren",
"prefs_appearance_title": "Weergave",
"prefs_appearance_language_title": "Taal",
"priority_min": "min",
"priority_low": "laag",
"priority_default": "standaard",
"priority_high": "hoog",
"priority_max": "max",
"error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund"
}

View File

@@ -34,159 +34,5 @@
"notifications_attachment_link_expires": "link expira em {{date}}", "notifications_attachment_link_expires": "link expira em {{date}}",
"notifications_attachment_copy_url_button": "Copiar URL", "notifications_attachment_copy_url_button": "Copiar URL",
"notifications_attachment_link_expired": "link para transferência expirado", "notifications_attachment_link_expired": "link para transferência expirado",
"notifications_example": "Exemplo", "notifications_example": "Exemplo"
"notifications_more_details": "Para mais informações, confira <websiteLink>site</websiteLink> ou <docsLink>documentação</docsLink>.",
"notifications_loading": "Carregando notificações…",
"subscribe_dialog_error_user_anonymous": "anônimo",
"prefs_notifications_delete_after_three_hours": "Após três horas",
"prefs_notifications_delete_after_one_day": "Após um dia",
"prefs_notifications_delete_after_one_week": "Após uma semana",
"prefs_notifications_delete_after_one_month": "Após um mês",
"notifications_actions_not_supported": "Ação não suportada no aplicativo web",
"notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}",
"notifications_actions_open_url_title": "Ir para {{url}}",
"publish_dialog_title_topic": "Publicar em {{topic}}",
"publish_dialog_title_no_topic": "Publicar notificação",
"publish_dialog_progress_uploading": "Enviando …",
"publish_dialog_progress_uploading_detail": "Fazendo upload de {{loaded}}/{{total}} ({{percent}}%)…",
"publish_dialog_message_published": "Notificação publicada",
"publish_dialog_attachment_limits_file_reached": "excede o limite de arquivo {{fileSizeLimit}}",
"publish_dialog_priority_min": "Prioridade mínima",
"publish_dialog_priority_low": "Baixa prioridade",
"publish_dialog_priority_default": "Prioridade padrão",
"publish_dialog_base_url_label": "URL de serviço",
"publish_dialog_base_url_placeholder": "URL de serviço, por exemplo https://example.com",
"publish_dialog_topic_label": "Nome do tópico",
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts",
"publish_dialog_title_label": "Título",
"publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco",
"publish_dialog_message_label": "Mensagem",
"publish_dialog_message_placeholder": "Digite uma mensagem aqui",
"publish_dialog_tags_label": "Etiquetas",
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup",
"publish_dialog_priority_label": "Prioridade",
"publish_dialog_click_label": "Clique em URL",
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Email para encaminhar a notificação, por exemplo phil@example.com",
"publish_dialog_filename_label": "Nome do arquivo",
"publish_dialog_filename_placeholder": "Nome do arquivo anexado",
"publish_dialog_delay_label": "Atraso",
"publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo {{{unixTimestamp}}, {{relativeTime}}, ou \"{{naturalLanguage}}\" (apenas em inglês)",
"publish_dialog_other_features": "Outros recursos:",
"publish_dialog_chip_click_label": "Clique em URL",
"publish_dialog_chip_attach_file_label": "Anexar arquivo local",
"publish_dialog_chip_delay_label": "Atraso na entrega",
"publish_dialog_chip_topic_label": "Alterar tópico",
"publish_dialog_button_cancel_sending": "Cancelar o envio",
"publish_dialog_attached_file_filename_placeholder": "Nome do arquivo anexado",
"publish_dialog_drop_file_here": "Solte o arquivo aqui",
"emoji_picker_search_placeholder": "Pesquisar emoji",
"subscribe_dialog_subscribe_title": "Inscrever no tópico",
"subscribe_dialog_subscribe_use_another_label": "Usar outro servidor",
"subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por senha, então escolha um nome que não seja fácil de adivinhar. Uma vez inscrito, você pode PUT/POST notificações.",
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo phil_alerts",
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
"subscribe_dialog_subscribe_button_subscribe": "Inscrever",
"prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)",
"prefs_notifications_min_priority_any": "Qualquer prioridade",
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
"subscribe_dialog_login_password_label": "Senha",
"subscribe_dialog_login_button_back": "Voltar",
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
"prefs_notifications_delete_after_title": "Apagar notificações",
"prefs_notifications_delete_after_never": "Nunca",
"prefs_notifications_delete_after_never_description": "Notificações nunca serão auto excluídas",
"prefs_users_description": "Adicionar/remover usuários em seus tópicos protegidos. Note que o usuário e senha são salvos no armazenamento local do navegador.",
"prefs_users_add_button": "Adicionar usuário",
"prefs_users_table_user_header": "Usuário",
"prefs_users_table_base_url_header": "URL de serviço",
"prefs_users_dialog_title_add": "Adicionar usuário",
"prefs_users_dialog_title_edit": "Editar usuário",
"prefs_users_dialog_base_url_label": "URL de serviço, exemplo https://ntfy.sh",
"prefs_users_dialog_username_label": "Usuário, por exemplo phil",
"prefs_users_dialog_password_label": "Senha",
"prefs_users_dialog_button_cancel": "Cancelar",
"prefs_users_dialog_button_add": "Adicionar",
"prefs_users_dialog_button_save": "Salvar",
"prefs_appearance_title": "Aparência",
"prefs_appearance_language_title": "LInguagem",
"priority_min": "minima",
"priority_low": "baixa",
"priority_default": "padrão",
"priority_high": "alta",
"priority_max": "máxima",
"error_boundary_title": "Ah não, ntfy parou de funcionar",
"error_boundary_gathering_info": "Coletar mais informações …",
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.<br/>Se tiver um minuto, por favor <githubLink> relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha",
"error_boundary_stack_trace": "Rastreamento de pilha",
"publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante",
"publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restantes",
"publish_dialog_priority_high": "Alta prioridade",
"publish_dialog_priority_max": "Prioridade máxima",
"publish_dialog_button_send": "Enviar",
"publish_dialog_attached_file_title": "Arquivo anexado:",
"publish_dialog_attach_label": "URL de anexo",
"publish_dialog_chip_attach_url_label": "Anexar arquivo por URL",
"publish_dialog_attach_placeholder": "Anexar arquivo por URL, por exemplo, https://f-droid.org/F-Droid.apk",
"publish_dialog_chip_email_label": "Encaminhar para email",
"publish_dialog_checkbox_publish_another": "Publicar outro",
"publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todos os recursos de envio, consulte a <docsLink>documentação</docsLink>.",
"publish_dialog_button_cancel": "Cancelar",
"prefs_notifications_delete_after_one_day_description": "Notificações são automaticamente excluídas após um dia",
"prefs_notifications_delete_after_one_month_description": "Notificações são automaticamente excluídas após um mês",
"prefs_users_title": "Gerenciar usuários",
"subscribe_dialog_error_user_not_authorized": "Usuário {{username}} não autorizado",
"prefs_notifications_title": "Notificações",
"prefs_notifications_sound_no_sound": "Sem som",
"subscribe_dialog_login_title": "Login necessário",
"prefs_notifications_sound_title": "Som de notificações",
"prefs_notifications_min_priority_title": "Mínima prioridade",
"prefs_notifications_min_priority_description_any": "Mostrando todas as notificações, independente da prioridade",
"prefs_notifications_delete_after_one_week_description": "Notificações são automaticamente excluídas após uma semana",
"subscribe_dialog_login_description": "Esse tópico é protegido por senha. Por favor digite o nome de usuário e senha para inscrever.",
"subscribe_dialog_login_username_label": "Nome, por exemplo phil",
"subscribe_dialog_login_button_login": "Login",
"prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam",
"prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam",
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima",
"prefs_notifications_delete_after_three_hours_description": "Notificações são automaticamente excluídas após três horas",
"publish_dialog_attach_reset": "Remover URL do anexo",
"publish_dialog_emoji_picker_show": "Escolher emoji",
"publish_dialog_attached_file_remove": "Remover arquivo anexado",
"emoji_picker_search_clear": "Limpar",
"subscribe_dialog_subscribe_base_url_label": "URL de subscrição",
"notifications_list": "Lista de notificações",
"message_bar_show_dialog": "Mostrar caixa de publicação",
"publish_dialog_topic_reset": "Resetar tópico",
"publish_dialog_delay_reset": "Remover entrega adiada da notificação",
"nav_button_connecting": "Conectando",
"publish_dialog_email_reset": "Remover encaminhar email",
"prefs_notifications_sound_play": "Reproduzir som selecionado",
"action_bar_show_menu": "Mostrar menu",
"action_bar_toggle_mute": "Habilita/Desabilita notificações",
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
"action_bar_logo_alt": "nfty logo",
"message_bar_publish": "Publicar mensagem",
"nav_button_muted": "Notificações desabilitadas",
"notifications_list_item": "Notificação",
"notifications_mark_read": "Marcar como lido",
"notifications_delete": "Excluir",
"notifications_priority_x": "Prioridade {{priority}}",
"notifications_new_indicator": "Nova notificação",
"notifications_attachment_image": "Imagem anexada",
"notifications_attachment_file_image": "Arquivo de imagem",
"notifications_attachment_file_video": "Arquivo de vídeo",
"notifications_attachment_file_audio": "Arquivo de áudio",
"notifications_attachment_file_app": "Arquivo apk android",
"notifications_attachment_file_document": "Outros documentos",
"publish_dialog_click_reset": "Remover URL clicável",
"prefs_users_table": "Tabela de usuários",
"prefs_users_edit_button": "Editar usuário",
"prefs_users_delete_button": "Excluir usuário",
"error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada",
"error_boundary_unsupported_indexeddb_description": "O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.<br/><br/>Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso <githubLink>nesta edição do GitHub</githubLink>, ou falar conosco em <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>."
} }

View File

@@ -152,40 +152,5 @@
"prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez", "prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez",
"priority_high": "yüksek", "priority_high": "yüksek",
"notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor", "notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor",
"notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder", "notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder"
"action_bar_show_menu": "Menüyü göster",
"action_bar_logo_alt": "ntfy logosu",
"action_bar_toggle_action_menu": "Eylem menüsünü aç/kapat",
"message_bar_show_dialog": "Yayınla iletişim kutusunu göster",
"message_bar_publish": "Mesaj yayınla",
"nav_button_connecting": "bağlanıyor",
"notifications_list": "Bildirimler listesi",
"notifications_list_item": "Bildirim",
"notifications_delete": "Sil",
"notifications_attachment_image": "Ek resmi",
"notifications_attachment_file_image": "resim dosyası",
"notifications_attachment_file_video": "video dosyası",
"notifications_attachment_file_audio": "ses dosyası",
"notifications_attachment_file_app": "Android uygulama dosyası",
"notifications_attachment_file_document": "diğer belge",
"publish_dialog_emoji_picker_show": "Emoji seç",
"publish_dialog_topic_reset": "Konuyu sıfırla",
"publish_dialog_attach_reset": "Ek URL'sini kaldır",
"publish_dialog_delay_reset": "Gecikmeli teslimatı kaldır",
"publish_dialog_attached_file_remove": "Ekli dosyayı kaldır",
"emoji_picker_search_clear": "Aramayı temizle",
"subscribe_dialog_subscribe_base_url_label": "Hizmet URL'si",
"prefs_notifications_sound_play": "Seçilen sesi çal",
"error_boundary_unsupported_indexeddb_description": "ntfy web uygulamasının çalışması için IndexedDB'ye ihtiyacı var ve tarayıcınız gizli tarama modunda IndexedDB'yi desteklemiyor.<br/><br/>Bu talihsiz olsa da, ntfy web uygulamasını gizli tarama modunda kullanmak pek mantıklı değildir, çünkü her şey tarayıcı deposunda saklanır. <githubLink>Bu GitHub sorununda</githubLink> bununla ilgili daha fazla bilgi edinebilir veya <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> üzerinden bizimle konuşabilirsiniz.",
"notifications_new_indicator": "Yeni bildirim",
"action_bar_toggle_mute": "Bildirimleri sesini kapat/aç",
"publish_dialog_click_reset": "Tıklama URL'sini kaldır",
"prefs_users_table": "Kullanıcılar tablosu",
"error_boundary_unsupported_indexeddb_title": "Gizli tarama desteklenmiyor",
"nav_button_muted": "Bildirimler sessize alındı",
"notifications_mark_read": "Okundu olarak işaretle",
"notifications_priority_x": "Öncelik {{priority}}",
"publish_dialog_email_reset": "E-posta yönlendirmesini kaldır",
"prefs_users_edit_button": "Kullanıcıyı düzenle",
"prefs_users_delete_button": "Kullanıcı sil"
} }

View File

@@ -1,191 +0,0 @@
{
"action_bar_show_menu": "显示菜单",
"action_bar_logo_alt": "ntfy图标",
"action_bar_settings": "设置",
"action_bar_send_test_notification": "发送测试通知",
"action_bar_clear_notifications": "清除所有通知",
"action_bar_unsubscribe": "取消订阅",
"action_bar_toggle_action_menu": "开启或关闭操作菜单",
"message_bar_type_message": "在此处输入消息",
"message_bar_show_dialog": "显示发布对话框",
"message_bar_publish": "发布消息",
"nav_topics_title": "订阅主题",
"nav_button_all_notifications": "全部通知",
"nav_button_documentation": "文档",
"nav_button_publish_message": "发布通知",
"nav_button_subscribe": "订阅主题",
"nav_button_connecting": "正在连接",
"alert_grant_title": "已禁用通知",
"alert_grant_description": "授予浏览器显示桌面通知的权限。",
"alert_grant_button": "现在授予",
"alert_not_supported_title": "不支持通知",
"alert_not_supported_description": "您的浏览器不支持通知。",
"notifications_list": "通知列表",
"notifications_list_item": "通知",
"notifications_mark_read": "标记为已读",
"notifications_copied_to_clipboard": "复制到剪贴板",
"notifications_tags": "标记",
"notifications_priority_x": "优先级 {{priority}}",
"notifications_new_indicator": "新通知",
"notifications_attachment_open_button": "打开附件",
"notifications_attachment_link_expires": "链接过期 {{date}}",
"notifications_attachment_link_expired": "下载链接已过期",
"notifications_attachment_file_image": "图片文件",
"notifications_attachment_image": "附件图片",
"notifications_attachment_file_video": "视频文件",
"notifications_attachment_file_audio": "音频文件",
"notifications_attachment_file_app": "安卓应用文件",
"notifications_attachment_file_document": "其他文件",
"notifications_click_copy_url_title": "复制链接地址到剪贴板",
"notifications_click_copy_url_button": "复制链接",
"notifications_click_open_button": "打开链接",
"action_bar_toggle_mute": "暂停或恢复通知",
"nav_button_muted": "已暂停通知",
"notifications_actions_not_supported": "网页应用程序不支持操作",
"notifications_none_for_topic_title": "您尚未收到有关此主题的任何通知。",
"notifications_none_for_any_title": "您尚未收到任何通知。",
"notifications_none_for_any_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。以下是使用您的主题的示例。",
"notifications_no_subscriptions_title": "看起来你还没有任何订阅。",
"notifications_example": "示例",
"notifications_more_details": "有关更多信息,请查看<websiteLink>网站</websiteLink>或<docsLink>文档</docsLink>。",
"notifications_loading": "正在加载通知……",
"publish_dialog_title_topic": "发布到 {{topic}}",
"publish_dialog_title_no_topic": "发布通知",
"publish_dialog_progress_uploading": "正在上传……",
"publish_dialog_progress_uploading_detail": "正在上传 {{loaded}}/{{total}} ({{percent}}%) ……",
"publish_dialog_message_published": "已发布通知",
"publish_dialog_attachment_limits_file_and_quota_reached": "超过 {{fileSizeLimit}} 文件限制和配额,剩余 {{remainingBytes}}",
"publish_dialog_emoji_picker_show": "选择表情符号",
"publish_dialog_priority_min": "最低优先级",
"publish_dialog_priority_low": "低优先级",
"publish_dialog_priority_default": "默认优先级",
"publish_dialog_priority_high": "高优先级",
"publish_dialog_priority_max": "最高优先级",
"publish_dialog_topic_label": "主题名称",
"publish_dialog_topic_placeholder": "主题名称,例如 phil_alerts",
"publish_dialog_topic_reset": "重置主题",
"publish_dialog_title_label": "主题",
"publish_dialog_message_label": "消息",
"publish_dialog_message_placeholder": "在此输入消息",
"publish_dialog_tags_label": "标记",
"publish_dialog_priority_label": "优先级",
"publish_dialog_base_url_label": "服务链接地址",
"publish_dialog_base_url_placeholder": "服务链接地址,例如 https://example.com",
"publish_dialog_click_label": "点击链接地址",
"publish_dialog_click_placeholder": "点击通知时打开链接地址",
"publish_dialog_email_placeholder": "将通知转发到的地址,例如 phil@example.com",
"publish_dialog_email_reset": "移除电子邮件转发",
"publish_dialog_filename_label": "文件名",
"publish_dialog_filename_placeholder": "附件文件名",
"publish_dialog_delay_label": "延期",
"publish_dialog_other_features": "其它功能:",
"publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk",
"publish_dialog_delay_reset": "删除延迟交付",
"publish_dialog_attach_reset": "移除附件链接地址",
"publish_dialog_chip_click_label": "点击链接地址",
"publish_dialog_chip_email_label": "转发邮件",
"publish_dialog_chip_attach_file_label": "本地文件附件",
"publish_dialog_chip_topic_label": "变更主题",
"publish_dialog_button_cancel_sending": "取消发送",
"publish_dialog_checkbox_publish_another": "发布另一个",
"publish_dialog_attached_file_title": "附件文件:",
"publish_dialog_attached_file_filename_placeholder": "附件文件名",
"publish_dialog_attached_file_remove": "删除附件文件",
"publish_dialog_drop_file_here": "将文件拖拽至此",
"emoji_picker_search_placeholder": "查找表情符号",
"emoji_picker_search_clear": "清除搜索",
"subscribe_dialog_subscribe_title": "订阅主题",
"publish_dialog_chip_delay_label": "延迟交付",
"publish_dialog_chip_attach_url_label": "链接附件地址",
"subscribe_dialog_subscribe_use_another_label": "使用其他服务器",
"subscribe_dialog_subscribe_button_subscribe": "订阅",
"subscribe_dialog_login_title": "请登录",
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
"subscribe_dialog_login_username_label": "用户名,例如 phil",
"subscribe_dialog_login_password_label": "密码",
"subscribe_dialog_login_button_back": "返回",
"subscribe_dialog_login_button_login": "登录",
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
"subscribe_dialog_error_user_anonymous": "匿名",
"prefs_notifications_title": "通知",
"prefs_notifications_sound_title": "通知提示音",
"prefs_notifications_sound_description_none": "收到通知时不播放任何声音",
"prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音",
"prefs_notifications_sound_no_sound": "静音",
"prefs_notifications_sound_play": "播放选中声音",
"prefs_notifications_min_priority_title": "最低优先级",
"prefs_notifications_min_priority_description_x_or_higher": "仅显示优先级为{{number}}{{name}})或以上的通知",
"prefs_notifications_min_priority_description_max": "仅显示最高优先级的通知",
"prefs_notifications_min_priority_any": "任意优先级",
"prefs_notifications_min_priority_low_and_higher": "低优先级和更高优先级",
"prefs_notifications_min_priority_default_and_higher": "默认优先级或更高优先级",
"prefs_notifications_min_priority_high_and_higher": "高优先级或更高优先级",
"prefs_notifications_min_priority_max_only": "仅最高优先级",
"prefs_notifications_delete_after_never": "从不",
"prefs_notifications_delete_after_one_month": "一月后",
"prefs_notifications_delete_after_one_week": "一周后",
"prefs_notifications_delete_after_never_description": "永不自动删除通知",
"prefs_notifications_delete_after_three_hours_description": "三小时后自动删除通知",
"prefs_notifications_delete_after_one_day_description": "一天后自动删除通知",
"prefs_notifications_delete_after_one_week_description": "一周后自动删除通知",
"prefs_notifications_delete_after_one_month_description": "一月后后自动删除通知",
"prefs_users_title": "管理用户",
"prefs_users_description": "在此处添加/删除受保护主题的用户。请注意,用户名和密码存储在浏览器的本地存储中。",
"prefs_users_add_button": "添加用户",
"prefs_users_dialog_title_add": "添加用户",
"prefs_users_dialog_title_edit": "编辑用户",
"prefs_users_dialog_username_label": "用户名,例如 phil",
"prefs_users_dialog_password_label": "密码",
"prefs_users_dialog_button_cancel": "取消",
"prefs_users_dialog_button_save": "保存",
"prefs_appearance_title": "外观",
"prefs_appearance_language_title": "语言",
"priority_min": "最低",
"priority_low": "低",
"priority_default": "默认",
"priority_high": "高",
"priority_max": "最高",
"error_boundary_title": "天啊ntfy 崩溃了",
"prefs_users_table_base_url_header": "服务链接地址",
"prefs_users_dialog_base_url_label": "服务链接地址,例如 https://ntfy.sh",
"error_boundary_button_copy_stack_trace": "复制堆栈跟踪",
"error_boundary_stack_trace": "堆栈跟踪",
"error_boundary_gathering_info": "收集更多信息……",
"error_boundary_unsupported_indexeddb_title": "不支持隐私浏览",
"error_boundary_unsupported_indexeddb_description": "Ntfy Web应用程序需要IndexedDB才能运行并且您的浏览器在私隐私浏览模式下不支持IndexedDB。<br/><br/>虽然这很不幸但在隐私浏览模式下使用ntfy Web应用程序也没有多大意义因为所有东西都存储在浏览器存储中。您可以在<githubLink>本GitHub问题</githubLink>中阅读有关它的更多信息,或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上与我们交谈。",
"message_bar_error_publishing": "发布通知时出错",
"nav_button_settings": "设置",
"notifications_delete": "删除",
"notifications_attachment_copy_url_title": "将附件中链接地址复制到剪贴板",
"notifications_attachment_copy_url_button": "复制链接地址",
"notifications_attachment_open_title": "转到 {{url}}",
"notifications_actions_http_request_title": "发送 HTTP {{method}} 到 {{url}}",
"notifications_actions_open_url_title": "转到 {{url}}",
"notifications_none_for_topic_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。",
"subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
"publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
"publish_dialog_email_label": "电子邮件",
"publish_dialog_button_send": "发送",
"publish_dialog_attachment_limits_quota_reached": "超过配额,剩余 {{remainingBytes}}",
"publish_dialog_attach_label": "附件链接地址",
"publish_dialog_click_reset": "移除点击连接地址",
"publish_dialog_button_cancel": "取消",
"subscribe_dialog_subscribe_button_cancel": "取消",
"subscribe_dialog_subscribe_base_url_label": "服务地址地址",
"prefs_notifications_min_priority_description_any": "显示所有通知,无论优先级如何",
"prefs_notifications_delete_after_title": "删除通知",
"prefs_notifications_delete_after_three_hours": "三小时后",
"prefs_users_delete_button": "删除用户",
"prefs_users_table_user_header": "用户",
"prefs_users_dialog_button_add": "添加",
"prefs_notifications_delete_after_one_day": "一天后",
"error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。",
"prefs_users_table": "用户表",
"prefs_users_edit_button": "编辑用户",
"publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易猜测的名字。订阅后,您可以使用 PUT/POST 通知。",
"publish_dialog_delay_placeholder": "延迟交付,例如{{unixTimestamp}}、{{relativeTime}}或“{{naturalLanguage}}”(仅限英语)"
}

View File

@@ -115,12 +115,6 @@ class SubscriptionManager {
.delete(); .delete();
} }
async markNotificationRead(notificationId) {
await db.notifications
.where({id: notificationId})
.modify({new: 0});
}
async markNotificationsRead(subscriptionId) { async markNotificationsRead(subscriptionId) {
await db.notifications await db.notifications
.where({subscriptionId: subscriptionId, new: 1}) .where({subscriptionId: subscriptionId, new: 1})

View File

@@ -26,7 +26,6 @@ import {
unmatchedTags unmatchedTags
} from "../app/utils"; } from "../app/utils";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles"; import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
import {useLiveQuery} from "dexie-react-hooks"; import {useLiveQuery} from "dexie-react-hooks";
@@ -99,7 +98,7 @@ const NotificationList = (props) => {
> >
<Container <Container
maxWidth="md" maxWidth="md"
role="list" role="list"
aria-label={t("notifications_list")} aria-label={t("notifications_list")}
sx={{ sx={{
marginTop: 3, marginTop: 3,
@@ -136,10 +135,6 @@ const NotificationItem = (props) => {
console.log(`[Notifications] Deleting notification ${notification.id}`); console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id) await subscriptionManager.deleteNotification(notification.id)
} }
const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`);
await subscriptionManager.markNotificationRead(notification.id)
}
const handleCopy = (s) => { const handleCopy = (s) => {
navigator.clipboard.writeText(s); navigator.clipboard.writeText(s);
props.onShowSnack(); props.onShowSnack();
@@ -152,17 +147,9 @@ const NotificationItem = (props) => {
return ( return (
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}> <Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<CardContent> <CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}> <IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}> <CloseIcon />
<CloseIcon /> </IconButton>
</IconButton>
</Tooltip>
{notification.new === 1 &&
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}>
<CheckIcon />
</IconButton>
</Tooltip>}
<Typography sx={{ fontSize: 14 }} color="text.secondary"> <Typography sx={{ fontSize: 14 }} color="text.secondary">
{date} {date}
{[1,2,4,5].includes(notification.priority) && {[1,2,4,5].includes(notification.priority) &&

View File

@@ -436,7 +436,7 @@ const Appearance = () => {
const Language = () => { const Language = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const labelId = "prefLanguage"; const labelId = "prefLanguage";
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
const lang = i18n.language ?? "en"; const lang = i18n.language ?? "en";
@@ -449,19 +449,14 @@ const Language = () => {
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}> <Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
<MenuItem value="en">English</MenuItem> <MenuItem value="en">English</MenuItem>
<MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="bg">Български</MenuItem> <MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem> <MenuItem value="cs">Čeština</MenuItem>
<MenuItem value="zh_Hans">中文</MenuItem>
<MenuItem value="de">Deutsch</MenuItem> <MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="es">Español</MenuItem> <MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem> <MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem> <MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="ja">日本語</MenuItem> <MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nl">Nederlands</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem> <MenuItem value="nb_NO">Norsk bokmål</MenuItem>
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="ru">Русский</MenuItem> <MenuItem value="ru">Русский</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem> <MenuItem value="tr">Türkçe</MenuItem>
</Select> </Select>