Merge branch 'main' of github.com:binwiederhier/ntfy into HEAD
							
								
								
									
										28
									
								
								.github/workflows/test.yaml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,28 @@ | |||
| name: test | ||||
| on: [push, pull_request] | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Install Go | ||||
|         uses: actions/setup-go@v2 | ||||
|         with: | ||||
|           go-version: '1.17.x' | ||||
|       - name: Install node | ||||
|         uses: actions/setup-node@v2 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Install dependencies | ||||
|         run: sudo apt update && sudo apt install -y python3-pip curl | ||||
|       - name: Build docs (required for tests) | ||||
|         run: make docs | ||||
|       - name: Build web app (required for tests) | ||||
|         run: make web | ||||
|       - name: Run tests, formatting, vetting and linting | ||||
|         run: make check | ||||
|       - name: Run coverage | ||||
|         run: make coverage | ||||
|       - name: Upload coverage to codecov.io | ||||
|         run: make coverage-upload | ||||
							
								
								
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						|  | @ -1,5 +1,9 @@ | |||
| dist/ | ||||
| build/ | ||||
| .idea/ | ||||
| site/ | ||||
| server/docs/ | ||||
| server/site/ | ||||
| tools/fbsend/fbsend | ||||
| playground/ | ||||
| *.iml | ||||
| node_modules/ | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| before: | ||||
|   hooks: | ||||
|     - go mod download | ||||
|     - go mod tidy | ||||
| builds: | ||||
|   - | ||||
|     id: ntfy | ||||
|  | @ -12,6 +13,9 @@ builds: | |||
|       - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" | ||||
|     goos: [linux] | ||||
|     goarch: [amd64] | ||||
|     hooks: | ||||
|       post: | ||||
|         - upx "{{ .Path }}" # apt install upx | ||||
|   - | ||||
|     id: ntfy_armv7 | ||||
|     binary: ntfy | ||||
|  | @ -24,6 +28,9 @@ builds: | |||
|     goos: [linux] | ||||
|     goarch: [arm] | ||||
|     goarm: [7] | ||||
|     hooks: | ||||
|       post: | ||||
|         - upx "{{ .Path }}" # apt install upx | ||||
|   - | ||||
|     id: ntfy_arm64 | ||||
|     binary: ntfy | ||||
|  | @ -35,6 +42,9 @@ builds: | |||
|       - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" | ||||
|     goos: [linux] | ||||
|     goarch: [arm64] | ||||
|     hooks: | ||||
|       post: | ||||
|         - upx "{{ .Path }}" # apt install upx | ||||
| nfpms: | ||||
|   - | ||||
|     package_name: ntfy | ||||
|  | @ -47,12 +57,26 @@ nfpms: | |||
|       - rpm | ||||
|     bindir: /usr/bin | ||||
|     contents: | ||||
|       - src: config/config.yml | ||||
|         dst: /etc/ntfy/config.yml | ||||
|         type: config | ||||
|       - src: config/ntfy.service | ||||
|       - src: server/server.yml | ||||
|         dst: /etc/ntfy/server.yml | ||||
|         type: "config|noreplace" | ||||
|       - src: server/ntfy.service | ||||
|         dst: /lib/systemd/system/ntfy.service | ||||
|       - src: client/client.yml | ||||
|         dst: /etc/ntfy/client.yml | ||||
|         type: "config|noreplace" | ||||
|       - src: client/ntfy-client.service | ||||
|         dst: /lib/systemd/system/ntfy-client.service | ||||
|       - dst: /var/cache/ntfy | ||||
|         type: dir | ||||
|       - dst: /var/cache/ntfy/attachments | ||||
|         type: dir | ||||
|       - dst: /var/lib/ntfy | ||||
|         type: dir | ||||
|       - dst: /usr/share/ntfy/logo.png | ||||
|         src: web/public/static/img/ntfy.png | ||||
|     scripts: | ||||
|       preinstall: "scripts/preinst.sh" | ||||
|       postinstall: "scripts/postinst.sh" | ||||
|       preremove: "scripts/prerm.sh" | ||||
|       postremove: "scripts/postrm.sh" | ||||
|  | @ -62,8 +86,10 @@ archives: | |||
|     files: | ||||
|       - LICENSE | ||||
|       - README.md | ||||
|       - config/config.yml | ||||
|       - config/ntfy.service | ||||
|       - server/server.yml | ||||
|       - server/ntfy.service | ||||
|       - client/client.yml | ||||
|       - client/ntfy-client.service | ||||
|     replacements: | ||||
|       386: i386 | ||||
|       amd64: x86_64 | ||||
|  | @ -89,6 +115,7 @@ dockers: | |||
|       - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" | ||||
|     use: buildx | ||||
|     dockerfile: Dockerfile | ||||
|     goarch: arm64 | ||||
|     build_flag_templates: | ||||
|       - "--platform=linux/arm64/v8" | ||||
|   - image_templates: | ||||
|  |  | |||
							
								
								
									
										2
									
								
								LICENSE
									
										
									
									
									
								
							
							
						
						|  | @ -186,7 +186,7 @@ | |||
|       same "printed page" as the copyright notice for easier | ||||
|       identification within third-party archives. | ||||
| 
 | ||||
|    Copyright [yyyy] [name of copyright owner] | ||||
|    Copyright 2021 Philipp C. Heckel | ||||
| 
 | ||||
|    Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|    you may not use this file except in compliance with the License. | ||||
|  |  | |||
|  | @ -290,8 +290,8 @@ to attach them to the start of each source file to most effectively | |||
| convey the exclusion of warranty; and each file should have at least | ||||
| the "copyright" line and a pointer to where the full notice is found. | ||||
| 
 | ||||
|     <one line to give the program's name and a brief idea of what it does.> | ||||
|     Copyright (C) <year>  <name of author> | ||||
|     ntfy | ||||
|     Copyright (C) 2021 Philipp C. Heckel | ||||
| 
 | ||||
|     This program is free software; you can redistribute it and/or modify | ||||
|     it under the terms of the GNU General Public License as published by | ||||
|  |  | |||
							
								
								
									
										86
									
								
								Makefile
									
										
									
									
									
								
							
							
						
						|  | @ -1,4 +1,3 @@ | |||
| GO=$(shell which go) | ||||
| VERSION := $(shell git describe --tag) | ||||
| 
 | ||||
| .PHONY: | ||||
|  | @ -38,25 +37,54 @@ help: | |||
| 	@echo "  make install-lint                - Install golint" | ||||
| 
 | ||||
| 
 | ||||
| # Documentation
 | ||||
| 
 | ||||
| docs-deps: .PHONY | ||||
| 	pip3 install -r requirements.txt | ||||
| 
 | ||||
| docs: docs-deps | ||||
| 	mkdocs build | ||||
| 
 | ||||
| 
 | ||||
| # Web app
 | ||||
| 
 | ||||
| web-deps: | ||||
| 	cd web \
 | ||||
| 		&& npm install \
 | ||||
| 		&& node_modules/svgo/bin/svgo src/img/*.svg | ||||
| 
 | ||||
| web-build: | ||||
| 	cd web \
 | ||||
| 		&& npm run build \
 | ||||
| 		&& mv build/index.html build/app.html \
 | ||||
| 		&& rm -rf ../server/site \
 | ||||
| 		&& mv build ../server/site \
 | ||||
| 		&& rm \
 | ||||
| 			../server/site/config.js \
 | ||||
| 			../server/site/asset-manifest.json | ||||
| 
 | ||||
| web: web-deps web-build | ||||
| 
 | ||||
| 
 | ||||
| # Test/check targets
 | ||||
| 
 | ||||
| check: test fmt-check vet lint staticcheck | ||||
| 
 | ||||
| test: .PHONY | ||||
| 	$(GO) test ./... | ||||
| 	go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') | ||||
| 
 | ||||
| race: .PHONY | ||||
| 	$(GO) test -race ./... | ||||
| 	go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') | ||||
| 
 | ||||
| coverage: | ||||
| 	mkdir -p build/coverage | ||||
| 	$(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... | ||||
| 	$(GO) tool cover -func build/coverage/coverage.txt | ||||
| 	go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') | ||||
| 	go tool cover -func build/coverage/coverage.txt | ||||
| 
 | ||||
| coverage-html: | ||||
| 	mkdir -p build/coverage | ||||
| 	$(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... | ||||
| 	$(GO) tool cover -html build/coverage/coverage.txt | ||||
| 	go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') | ||||
| 	go tool cover -html build/coverage/coverage.txt | ||||
| 
 | ||||
| coverage-upload: | ||||
| 	cd build/coverage && (curl -s https://codecov.io/bash | bash) | ||||
|  | @ -65,33 +93,30 @@ coverage-upload: | |||
| # Lint/formatting targets
 | ||||
| 
 | ||||
| fmt: | ||||
| 	$(GO) fmt ./... | ||||
| 	gofmt -s -w . | ||||
| 
 | ||||
| fmt-check: | ||||
| 	test -z $(shell gofmt -l .) | ||||
| 
 | ||||
| vet: | ||||
| 	$(GO) vet ./... | ||||
| 	go vet ./... | ||||
| 
 | ||||
| lint: | ||||
| 	which golint || $(GO) get -u golang.org/x/lint/golint | ||||
| 	$(GO) list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status | ||||
| 	which golint || go install golang.org/x/lint/golint@latest | ||||
| 	go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status | ||||
| 
 | ||||
| staticcheck: .PHONY | ||||
| 	rm -rf build/staticcheck | ||||
| 	which staticcheck || go get honnef.co/go/tools/cmd/staticcheck | ||||
| 	which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest | ||||
| 	mkdir -p build/staticcheck | ||||
| 	ln -s "$(GO)" build/staticcheck/go | ||||
| 	ln -s "go" build/staticcheck/go | ||||
| 	PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... | ||||
| 	rm -rf build/staticcheck | ||||
| 
 | ||||
| 
 | ||||
| # Building targets
 | ||||
| 
 | ||||
| docs: .PHONY | ||||
| 	mkdocs build | ||||
| 
 | ||||
| build-deps: docs | ||||
| build-deps: docs web | ||||
| 	which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } | ||||
| 	which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; } | ||||
| 
 | ||||
|  | @ -102,21 +127,38 @@ build-snapshot: build-deps | |||
| 	goreleaser build --snapshot --rm-dist --debug | ||||
| 
 | ||||
| build-simple: clean | ||||
| 	mkdir -p dist/ntfy_linux_amd64 | ||||
| 	mkdir -p dist/ntfy_linux_amd64 server/docs server/site | ||||
| 	touch server/docs/index.html | ||||
| 	touch server/site/app.html | ||||
| 	export CGO_ENABLED=1 | ||||
| 	$(GO) build \
 | ||||
| 	go build \
 | ||||
| 		-o dist/ntfy_linux_amd64/ntfy \
 | ||||
| 		-tags sqlite_omit_load_extension,osusergo,netgo \
 | ||||
| 		-ldflags \
 | ||||
| 		"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)" | ||||
| 
 | ||||
| clean: .PHONY | ||||
| 	rm -rf dist build | ||||
| 	rm -rf dist build server/docs server/site | ||||
| 
 | ||||
| 
 | ||||
| # Releasing targets
 | ||||
| 
 | ||||
| release: build-deps | ||||
| release-check-tags: | ||||
| 	$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) | ||||
| 	if ! grep -q $(LATEST_TAG) docs/install.md; then\
 | ||||
| 	 	echo "ERROR: Must update docs/install.md with latest tag first.";\
 | ||||
| 	 	exit 1;\
 | ||||
| 	fi | ||||
| 	if grep -q XXXXX docs/releases.md; then\
 | ||||
| 		echo "ERROR: Must update docs/releases.md, found XXXXX.";\
 | ||||
| 		exit 1;\
 | ||||
| 	fi | ||||
| 	if ! grep -q $(LATEST_TAG) docs/releases.md; then\
 | ||||
| 		echo "ERROR: Must update docs/releases.mdwith latest tag first.";\
 | ||||
| 		exit 1;\
 | ||||
| 	fi | ||||
| 
 | ||||
| release: build-deps release-check-tags check | ||||
| 	goreleaser release --rm-dist --debug | ||||
| 
 | ||||
| release-snapshot: build-deps | ||||
|  | @ -132,4 +174,4 @@ install: | |||
| install-deb: | ||||
| 	sudo systemctl stop ntfy || true | ||||
| 	sudo apt-get purge ntfy || true | ||||
| 	sudo dpkg -i dist/*.deb | ||||
| 	sudo dpkg -i dist/ntfy_*_linux_amd64.deb | ||||
|  |  | |||
							
								
								
									
										223
									
								
								README.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,8 +1,14 @@ | |||
|  | ||||
|  | ||||
| 
 | ||||
| # ntfy.sh | simple HTTP-based pub-sub | ||||
| # ntfy.sh | Send push notifications to your phone or desktop via PUT/POST | ||||
| [](https://github.com/binwiederhier/ntfy/releases/latest) | ||||
| [](https://gophers.slack.com/archives/C01JMTPGF2Q) | ||||
| [](https://pkg.go.dev/heckel.io/ntfy) | ||||
| [](https://github.com/binwiederhier/ntfy/actions) | ||||
| [](https://goreportcard.com/report/github.com/binwiederhier/ntfy) | ||||
| [](https://codecov.io/gh/binwiederhier/ntfy) | ||||
| [](https://discord.gg/cT7ECsZj9w) | ||||
| [](https://matrix.to/#/#ntfy:matrix.org) | ||||
| [](https://ntfy.statuspage.io/) | ||||
| 
 | ||||
| **ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. | ||||
| It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**. | ||||
|  | @ -12,195 +18,28 @@ I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [op | |||
| too. | ||||
| 
 | ||||
| <p> | ||||
|   <img src="server/static/img/screenshot-curl.png" height="180"> | ||||
|   <img src="server/static/img/screenshot-web-detail.png" height="180"> | ||||
|   <img src="server/static/img/screenshot-phone-main.jpg" height="180"> | ||||
|   <img src="server/static/img/screenshot-phone-detail.jpg" height="180"> | ||||
|   <img src="server/static/img/screenshot-phone-notification.jpg" height="180"> | ||||
|   <img src="web/public/static/img/screenshot-curl.png" height="180"> | ||||
|   <img src="web/public/static/img/screenshot-web-detail.png" height="180"> | ||||
|   <img src="web/public/static/img/screenshot-phone-main.jpg" height="180"> | ||||
|   <img src="web/public/static/img/screenshot-phone-detail.jpg" height="180"> | ||||
|   <img src="web/public/static/img/screenshot-phone-notification.jpg" height="180"> | ||||
| </p> | ||||
| 
 | ||||
| ## Usage | ||||
| ## **[Documentation](https://ntfy.sh/docs/)** | ||||
| 
 | ||||
| ### Publishing messages | ||||
| 
 | ||||
| Publishing messages can be done via PUT or POST using. Topics are created on the fly by subscribing or publishing to them. | ||||
| Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. | ||||
| 
 | ||||
| Here's an example showing how to publish a message using `curl`: | ||||
| 
 | ||||
| ``` | ||||
| curl -d "long process is done" ntfy.sh/mytopic | ||||
| ``` | ||||
| 
 | ||||
| Here's an example in JS with `fetch()` (see [full example](examples)): | ||||
| 
 | ||||
| ``` | ||||
| fetch('https://ntfy.sh/mytopic', { | ||||
|   method: 'POST', // PUT works too | ||||
|   body: 'Hello from the other side.' | ||||
| }) | ||||
| ``` | ||||
| 
 | ||||
| ### Subscribe to a topic | ||||
| You can create and subscribe to a topic either in this web UI, or in your own app by subscribing to an | ||||
| [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), a JSON feed, or raw feed. | ||||
| 
 | ||||
| #### Subscribe via web | ||||
| If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic | ||||
| will show up as **desktop notification**. | ||||
| 
 | ||||
| You can try this easily on **[ntfy.sh](https://ntfy.sh)**. | ||||
| 
 | ||||
| #### Subscribe via phone | ||||
| You can use the [Ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive  | ||||
| notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android). | ||||
| 
 | ||||
| #### Subscribe via your app, or via the CLI | ||||
| Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JS, you can consume | ||||
| notifications like this (see [full example](examples)): | ||||
| 
 | ||||
| ```javascript | ||||
| const eventSource = new EventSource('https://ntfy.sh/mytopic/sse');<br/> | ||||
| eventSource.onmessage = (e) => {<br/> | ||||
|   // Do something with e.data<br/> | ||||
| }; | ||||
| ``` | ||||
| 
 | ||||
| You can also use the same `/sse` endpoint via `curl` or any other HTTP library: | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s ntfy.sh/mytopic/sse | ||||
| event: open | ||||
| data: {"id":"weSj9RtNkj","time":1635528898,"event":"open","topic":"mytopic"} | ||||
| 
 | ||||
| data: {"id":"p0M5y6gcCY","time":1635528909,"event":"message","topic":"mytopic","message":"Hi!"} | ||||
| 
 | ||||
| event: keepalive | ||||
| data: {"id":"VNxNIg5fpt","time":1635528928,"event":"keepalive","topic":"test"} | ||||
| ``` | ||||
| 
 | ||||
| To consume JSON instead, use the `/json` endpoint, which prints one message per line: | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s ntfy.sh/mytopic/json | ||||
| {"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"} | ||||
| {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"} | ||||
| {"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"} | ||||
| ``` | ||||
| 
 | ||||
| Or use the `/raw` endpoint if you need something super simple (empty lines are keepalive messages): | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s ntfy.sh/mytopic/raw | ||||
| 
 | ||||
| This is a notification | ||||
| ``` | ||||
| 
 | ||||
| #### Message buffering and polling | ||||
| Messages are buffered in memory for a few hours to account for network interruptions of subscribers. | ||||
| You can read back what you missed by using the `since=...` query parameter. It takes either a | ||||
| duration (e.g. `10m` or `30s`) or a Unix timestamp (e.g. `1635528757`): | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s "ntfy.sh/mytopic/json?since=10m" | ||||
| # Same output as above, but includes messages from up to 10 minutes ago | ||||
| ``` | ||||
| 
 | ||||
| You can also just poll for messages if you don't like the long-standing connection using the `poll=1` | ||||
| query parameter. The connection will end after all available messages have been read. This parameter has to be | ||||
| combined with `since=`. | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m" | ||||
| # Returns messages from up to 10 minutes ago and ends the connection | ||||
| ``` | ||||
| 
 | ||||
| ## Examples | ||||
| There are a few usage examples in the [examples](examples) directory. I'm sure there are tons of other ways to use it. | ||||
| 
 | ||||
| ## Installation | ||||
| Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and | ||||
| deb/rpm packages. | ||||
| 
 | ||||
| 1. Install ntfy using one of the methods described below | ||||
| 2. Then (optionally) edit `/etc/ntfy/config.yml` | ||||
| 3. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm). | ||||
| 
 | ||||
| ### Binaries and packages | ||||
| **Debian/Ubuntu** (*from a repository*)**:** | ||||
| ```bash | ||||
| curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add - | ||||
| sudo apt install apt-transport-https | ||||
| sudo sh -c "echo 'deb [arch=amd64] https://archive.heckel.io/apt debian main' > /etc/apt/sources.list.d/archive.heckel.io.list"   | ||||
| sudo apt update | ||||
| sudo apt install ntfy | ||||
| ``` | ||||
| 
 | ||||
| **Debian/Ubuntu** (*manual install*)**:** | ||||
| ```bash | ||||
| wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_amd64.deb | ||||
| dpkg -i ntfy_1.5.0_amd64.deb | ||||
| ``` | ||||
| 
 | ||||
| **Fedora/RHEL/CentOS:** | ||||
| ```bash | ||||
| rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_amd64.rpm | ||||
| ``` | ||||
| 
 | ||||
| **Docker:** | ||||
| Without cache: | ||||
| ``` | ||||
| docker run -p 80:80 -it binwiederhier/ntfy | ||||
| ``` | ||||
| 
 | ||||
| With cache: | ||||
| ```bash | ||||
| docker run \ | ||||
|   -v /var/cache/ntfy:/var/cache/ntfy \ | ||||
|   -p 80:80 \ | ||||
|   -it \ | ||||
|   binwiederhier/ntfy \ | ||||
|     --cache-file /var/cache/ntfy/cache.db | ||||
| ``` | ||||
| 
 | ||||
| **Go:** | ||||
| ```bash | ||||
| go get -u heckel.io/ntfy | ||||
| ``` | ||||
| 
 | ||||
| **Manual install:** | ||||
| ```bash | ||||
| # x86_64/amd64 | ||||
| wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_x86_64.tar.gz | ||||
| 
 | ||||
| # armv7 | ||||
| wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.tar.gz | ||||
| 
 | ||||
| # arm64/v8 | ||||
| wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.tar.gz | ||||
| 
 | ||||
| # Extract and run | ||||
| sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy | ||||
| ./ntfy | ||||
| ``` | ||||
| 
 | ||||
| ## Building | ||||
| Building `ntfy` is simple. Here's how you do it: | ||||
| 
 | ||||
| ``` | ||||
| make build-simple | ||||
| # Builds to dist/ntfy_linux_amd64/ntfy | ||||
| ```  | ||||
| 
 | ||||
| To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that installed, you can run `make build` or | ||||
| `make build-snapshot`. | ||||
| [Getting started](https://ntfy.sh/docs/) | | ||||
| [Android/iOS](https://ntfy.sh/docs/subscribe/phone/) | | ||||
| [API](https://ntfy.sh/docs/publish/) | | ||||
| [Install / Self-hosting](https://ntfy.sh/docs/install/) | | ||||
| [Building](https://ntfy.sh/docs/develop/) | ||||
| 
 | ||||
| ## Contributing | ||||
| I welcome any and all contributions. Just create a PR or an issue. | ||||
| 
 | ||||
| ## Contact me | ||||
| You can directly contact me [on Slack](https://gophers.slack.com/archives/C01JMTPGF2Q), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), | ||||
| or find more contact information [on my website](https://heckel.io/about). | ||||
| 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 | ||||
| Made with ❤️ by [Philipp C. Heckel](https://heckel.io).    | ||||
|  | @ -208,11 +47,21 @@ The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GP | |||
| 
 | ||||
| Third party libraries and resources: | ||||
| * [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI | ||||
| * [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound | ||||
| * [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI | ||||
| * [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds | ||||
| * [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds | ||||
| * [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web | ||||
| * [React](https://reactjs.org/) (MIT) is used for the web app | ||||
| * [Material UI components](https://mui.com/) (MIT) are used in the web app | ||||
| * [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app | ||||
| * [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB | ||||
| * [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases | ||||
| * [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails | ||||
| * [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests | ||||
| * [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache | ||||
| * [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages | ||||
| * [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file) | ||||
| * [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)  | ||||
| * [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page  | ||||
| * [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files | ||||
| * [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used) | ||||
| * [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) | ||||
| * [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs) | ||||
|  |  | |||
							
								
								
									
										122
									
								
								auth/auth.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,122 @@ | |||
| // Package auth deals with authentication and authorization against topics | ||||
| package auth | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"regexp" | ||||
| ) | ||||
| 
 | ||||
| // Auther is a generic interface to implement password-based authentication and authorization | ||||
| type Auther interface { | ||||
| 	// Authenticate checks username and password and returns a user if correct. The method | ||||
| 	// returns in constant-ish time, regardless of whether the user exists or the password is | ||||
| 	// correct or incorrect. | ||||
| 	Authenticate(username, password string) (*User, error) | ||||
| 
 | ||||
| 	// Authorize returns nil if the given user has access to the given topic using the desired | ||||
| 	// permission. The user param may be nil to signal an anonymous user. | ||||
| 	Authorize(user *User, topic string, perm Permission) error | ||||
| } | ||||
| 
 | ||||
| // Manager is an interface representing user and access management | ||||
| type Manager interface { | ||||
| 	// AddUser adds a user with the given username, password and role. The password should be hashed | ||||
| 	// before it is stored in a persistence layer. | ||||
| 	AddUser(username, password string, role Role) error | ||||
| 
 | ||||
| 	// RemoveUser deletes the user with the given username. The function returns nil on success, even | ||||
| 	// if the user did not exist in the first place. | ||||
| 	RemoveUser(username string) error | ||||
| 
 | ||||
| 	// Users returns a list of users. It always also returns the Everyone user ("*"). | ||||
| 	Users() ([]*User, error) | ||||
| 
 | ||||
| 	// User returns the user with the given username if it exists, or ErrNotFound otherwise. | ||||
| 	// You may also pass Everyone to retrieve the anonymous user and its Grant list. | ||||
| 	User(username string) (*User, error) | ||||
| 
 | ||||
| 	// ChangePassword changes a user's password | ||||
| 	ChangePassword(username, password string) error | ||||
| 
 | ||||
| 	// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, | ||||
| 	// all existing access control entries (Grant) are removed, since they are no longer needed. | ||||
| 	ChangeRole(username string, role Role) error | ||||
| 
 | ||||
| 	// AllowAccess adds or updates an entry in th access control list for a specific user. It controls | ||||
| 	// read/write access to a topic. The parameter topicPattern may include wildcards (*). | ||||
| 	AllowAccess(username string, topicPattern string, read bool, write bool) error | ||||
| 
 | ||||
| 	// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is | ||||
| 	// empty) for an entire user. The parameter topicPattern may include wildcards (*). | ||||
| 	ResetAccess(username string, topicPattern string) error | ||||
| 
 | ||||
| 	// DefaultAccess returns the default read/write access if no access control entry matches | ||||
| 	DefaultAccess() (read bool, write bool) | ||||
| } | ||||
| 
 | ||||
| // User is a struct that represents a user | ||||
| type User struct { | ||||
| 	Name   string | ||||
| 	Hash   string // password hash (bcrypt) | ||||
| 	Role   Role | ||||
| 	Grants []Grant | ||||
| } | ||||
| 
 | ||||
| // Grant is a struct that represents an access control entry to a topic | ||||
| type Grant struct { | ||||
| 	TopicPattern string // May include wildcard (*) | ||||
| 	AllowRead    bool | ||||
| 	AllowWrite   bool | ||||
| } | ||||
| 
 | ||||
| // Permission represents a read or write permission to a topic | ||||
| type Permission int | ||||
| 
 | ||||
| // Permissions to a topic | ||||
| const ( | ||||
| 	PermissionRead  = Permission(1) | ||||
| 	PermissionWrite = Permission(2) | ||||
| ) | ||||
| 
 | ||||
| // Role represents a user's role, either admin or regular user | ||||
| type Role string | ||||
| 
 | ||||
| // User roles | ||||
| const ( | ||||
| 	RoleAdmin     = Role("admin") | ||||
| 	RoleUser      = Role("user") | ||||
| 	RoleAnonymous = Role("anonymous") | ||||
| ) | ||||
| 
 | ||||
| // Everyone is a special username representing anonymous users | ||||
| const ( | ||||
| 	Everyone = "*" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	allowedUsernameRegex     = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`)     // Does not include Everyone (*) | ||||
| 	allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! | ||||
| ) | ||||
| 
 | ||||
| // AllowedRole returns true if the given role can be used for new users | ||||
| func AllowedRole(role Role) bool { | ||||
| 	return role == RoleUser || role == RoleAdmin | ||||
| } | ||||
| 
 | ||||
| // AllowedUsername returns true if the given username is valid | ||||
| func AllowedUsername(username string) bool { | ||||
| 	return allowedUsernameRegex.MatchString(username) | ||||
| } | ||||
| 
 | ||||
| // AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) | ||||
| func AllowedTopicPattern(username string) bool { | ||||
| 	return allowedTopicPatternRegex.MatchString(username) | ||||
| } | ||||
| 
 | ||||
| // Error constants used by the package | ||||
| var ( | ||||
| 	ErrUnauthenticated = errors.New("unauthenticated") | ||||
| 	ErrUnauthorized    = errors.New("unauthorized") | ||||
| 	ErrInvalidArgument = errors.New("invalid argument") | ||||
| 	ErrNotFound        = errors.New("not found") | ||||
| ) | ||||
							
								
								
									
										399
									
								
								auth/auth_sqlite.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,399 @@ | |||
| package auth | ||||
| 
 | ||||
| import ( | ||||
| 	"database/sql" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	_ "github.com/mattn/go-sqlite3" // SQLite driver | ||||
| 	"golang.org/x/crypto/bcrypt" | ||||
| 	"strings" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	bcryptCost              = 10 | ||||
| 	intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost | ||||
| ) | ||||
| 
 | ||||
| // Auther-related queries | ||||
| const ( | ||||
| 	createAuthTablesQueries = ` | ||||
| 		BEGIN; | ||||
| 		CREATE TABLE IF NOT EXISTS user ( | ||||
| 			user TEXT NOT NULL PRIMARY KEY, | ||||
| 			pass TEXT NOT NULL, | ||||
| 			role TEXT NOT NULL | ||||
| 		); | ||||
| 		CREATE TABLE IF NOT EXISTS access ( | ||||
| 			user TEXT NOT NULL,		 | ||||
| 			topic TEXT NOT NULL, | ||||
| 			read INT NOT NULL, | ||||
| 			write INT NOT NULL, | ||||
| 			PRIMARY KEY (topic, user) | ||||
| 		); | ||||
| 		CREATE TABLE IF NOT EXISTS schemaVersion ( | ||||
| 			id INT PRIMARY KEY, | ||||
| 			version INT NOT NULL | ||||
| 		); | ||||
| 		COMMIT; | ||||
| 	` | ||||
| 	selectUserQuery       = `SELECT pass, role FROM user WHERE user = ?` | ||||
| 	selectTopicPermsQuery = ` | ||||
| 		SELECT read, write  | ||||
| 		FROM access  | ||||
| 		WHERE user IN ('*', ?) AND ? LIKE topic | ||||
| 		ORDER BY user DESC | ||||
| 	` | ||||
| ) | ||||
| 
 | ||||
| // Manager-related queries | ||||
| const ( | ||||
| 	insertUserQuery      = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` | ||||
| 	selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user` | ||||
| 	updateUserPassQuery  = `UPDATE user SET pass = ? WHERE user = ?` | ||||
| 	updateUserRoleQuery  = `UPDATE user SET role = ? WHERE user = ?` | ||||
| 	deleteUserQuery      = `DELETE FROM user WHERE user = ?` | ||||
| 
 | ||||
| 	upsertUserAccessQuery = ` | ||||
| 		INSERT INTO access (user, topic, read, write)  | ||||
| 		VALUES (?, ?, ?, ?) | ||||
| 		ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write | ||||
| 	` | ||||
| 	selectUserAccessQuery  = `SELECT topic, read, write FROM access WHERE user = ?` | ||||
| 	deleteAllAccessQuery   = `DELETE FROM access` | ||||
| 	deleteUserAccessQuery  = `DELETE FROM access WHERE user = ?` | ||||
| 	deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?` | ||||
| ) | ||||
| 
 | ||||
| // Schema management queries | ||||
| const ( | ||||
| 	currentSchemaVersion     = 1 | ||||
| 	insertSchemaVersion      = `INSERT INTO schemaVersion VALUES (1, ?)` | ||||
| 	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` | ||||
| ) | ||||
| 
 | ||||
| // SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list | ||||
| // in a SQLite database. | ||||
| type SQLiteAuth struct { | ||||
| 	db           *sql.DB | ||||
| 	defaultRead  bool | ||||
| 	defaultWrite bool | ||||
| } | ||||
| 
 | ||||
| var _ Auther = (*SQLiteAuth)(nil) | ||||
| var _ Manager = (*SQLiteAuth)(nil) | ||||
| 
 | ||||
| // NewSQLiteAuth creates a new SQLiteAuth instance | ||||
| func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) { | ||||
| 	db, err := sql.Open("sqlite3", filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if err := setupAuthDB(db); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &SQLiteAuth{ | ||||
| 		db:           db, | ||||
| 		defaultRead:  defaultRead, | ||||
| 		defaultWrite: defaultWrite, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| // Authenticate checks username and password and returns a user if correct. The method | ||||
| // returns in constant-ish time, regardless of whether the user exists or the password is | ||||
| // correct or incorrect. | ||||
| func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { | ||||
| 	if username == Everyone { | ||||
| 		return nil, ErrUnauthenticated | ||||
| 	} | ||||
| 	user, err := a.User(username) | ||||
| 	if err != nil { | ||||
| 		bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), | ||||
| 			[]byte("intentional slow-down to avoid timing attacks")) | ||||
| 		return nil, ErrUnauthenticated | ||||
| 	} | ||||
| 	if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { | ||||
| 		return nil, ErrUnauthenticated | ||||
| 	} | ||||
| 	return user, nil | ||||
| } | ||||
| 
 | ||||
| // Authorize returns nil if the given user has access to the given topic using the desired | ||||
| // permission. The user param may be nil to signal an anonymous user. | ||||
| func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error { | ||||
| 	if user != nil && user.Role == RoleAdmin { | ||||
| 		return nil // Admin can do everything | ||||
| 	} | ||||
| 	username := Everyone | ||||
| 	if user != nil { | ||||
| 		username = user.Name | ||||
| 	} | ||||
| 	// Select the read/write permissions for this user/topic combo. The query may return two | ||||
| 	// rows (one for everyone, and one for the user), but prioritizes the user. The value for | ||||
| 	// user.Name may be empty (= everyone). | ||||
| 	rows, err := a.db.Query(selectTopicPermsQuery, username, topic) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	if !rows.Next() { | ||||
| 		return a.resolvePerms(a.defaultRead, a.defaultWrite, perm) | ||||
| 	} | ||||
| 	var read, write bool | ||||
| 	if err := rows.Scan(&read, &write); err != nil { | ||||
| 		return err | ||||
| 	} else if err := rows.Err(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return a.resolvePerms(read, write, perm) | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error { | ||||
| 	if perm == PermissionRead && read { | ||||
| 		return nil | ||||
| 	} else if perm == PermissionWrite && write { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return ErrUnauthorized | ||||
| } | ||||
| 
 | ||||
| // AddUser adds a user with the given username, password and role. The password should be hashed | ||||
| // before it is stored in a persistence layer. | ||||
| func (a *SQLiteAuth) AddUser(username, password string, role Role) error { | ||||
| 	if !AllowedUsername(username) || !AllowedRole(role) { | ||||
| 		return ErrInvalidArgument | ||||
| 	} | ||||
| 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // RemoveUser deletes the user with the given username. The function returns nil on success, even | ||||
| // if the user did not exist in the first place. | ||||
| func (a *SQLiteAuth) RemoveUser(username string) error { | ||||
| 	if !AllowedUsername(username) { | ||||
| 		return ErrInvalidArgument | ||||
| 	} | ||||
| 	if _, err := a.db.Exec(deleteUserQuery, username); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // Users returns a list of users. It always also returns the Everyone user ("*"). | ||||
| func (a *SQLiteAuth) Users() ([]*User, error) { | ||||
| 	rows, err := a.db.Query(selectUsernamesQuery) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	usernames := make([]string, 0) | ||||
| 	for rows.Next() { | ||||
| 		var username string | ||||
| 		if err := rows.Scan(&username); err != nil { | ||||
| 			return nil, err | ||||
| 		} else if err := rows.Err(); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		usernames = append(usernames, username) | ||||
| 	} | ||||
| 	rows.Close() | ||||
| 	users := make([]*User, 0) | ||||
| 	for _, username := range usernames { | ||||
| 		user, err := a.User(username) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		users = append(users, user) | ||||
| 	} | ||||
| 	everyone, err := a.everyoneUser() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	users = append(users, everyone) | ||||
| 	return users, nil | ||||
| } | ||||
| 
 | ||||
| // User returns the user with the given username if it exists, or ErrNotFound otherwise. | ||||
| // You may also pass Everyone to retrieve the anonymous user and its Grant list. | ||||
| func (a *SQLiteAuth) User(username string) (*User, error) { | ||||
| 	if username == Everyone { | ||||
| 		return a.everyoneUser() | ||||
| 	} | ||||
| 	rows, err := a.db.Query(selectUserQuery, username) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	var hash, role string | ||||
| 	if !rows.Next() { | ||||
| 		return nil, ErrNotFound | ||||
| 	} | ||||
| 	if err := rows.Scan(&hash, &role); err != nil { | ||||
| 		return nil, err | ||||
| 	} else if err := rows.Err(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	grants, err := a.readGrants(username) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &User{ | ||||
| 		Name:   username, | ||||
| 		Hash:   hash, | ||||
| 		Role:   Role(role), | ||||
| 		Grants: grants, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) everyoneUser() (*User, error) { | ||||
| 	grants, err := a.readGrants(Everyone) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &User{ | ||||
| 		Name:   Everyone, | ||||
| 		Hash:   "", | ||||
| 		Role:   RoleAnonymous, | ||||
| 		Grants: grants, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) { | ||||
| 	rows, err := a.db.Query(selectUserAccessQuery, username) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	grants := make([]Grant, 0) | ||||
| 	for rows.Next() { | ||||
| 		var topic string | ||||
| 		var read, write bool | ||||
| 		if err := rows.Scan(&topic, &read, &write); err != nil { | ||||
| 			return nil, err | ||||
| 		} else if err := rows.Err(); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		grants = append(grants, Grant{ | ||||
| 			TopicPattern: fromSQLWildcard(topic), | ||||
| 			AllowRead:    read, | ||||
| 			AllowWrite:   write, | ||||
| 		}) | ||||
| 	} | ||||
| 	return grants, nil | ||||
| } | ||||
| 
 | ||||
| // ChangePassword changes a user's password | ||||
| func (a *SQLiteAuth) ChangePassword(username, password string) error { | ||||
| 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, | ||||
| // all existing access control entries (Grant) are removed, since they are no longer needed. | ||||
| func (a *SQLiteAuth) ChangeRole(username string, role Role) error { | ||||
| 	if !AllowedUsername(username) || !AllowedRole(role) { | ||||
| 		return ErrInvalidArgument | ||||
| 	} | ||||
| 	if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if role == RoleAdmin { | ||||
| 		if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // AllowAccess adds or updates an entry in th access control list for a specific user. It controls | ||||
| // read/write access to a topic. The parameter topicPattern may include wildcards (*). | ||||
| func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error { | ||||
| 	if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { | ||||
| 		return ErrInvalidArgument | ||||
| 	} | ||||
| 	if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is | ||||
| // empty) for an entire user. The parameter topicPattern may include wildcards (*). | ||||
| func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error { | ||||
| 	if !AllowedUsername(username) && username != Everyone && username != "" { | ||||
| 		return ErrInvalidArgument | ||||
| 	} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { | ||||
| 		return ErrInvalidArgument | ||||
| 	} | ||||
| 	if username == "" && topicPattern == "" { | ||||
| 		_, err := a.db.Exec(deleteAllAccessQuery, username) | ||||
| 		return err | ||||
| 	} else if topicPattern == "" { | ||||
| 		_, err := a.db.Exec(deleteUserAccessQuery, username) | ||||
| 		return err | ||||
| 	} | ||||
| 	_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern)) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // DefaultAccess returns the default read/write access if no access control entry matches | ||||
| func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) { | ||||
| 	return a.defaultRead, a.defaultWrite | ||||
| } | ||||
| 
 | ||||
| func toSQLWildcard(s string) string { | ||||
| 	return strings.ReplaceAll(s, "*", "%") | ||||
| } | ||||
| 
 | ||||
| func fromSQLWildcard(s string) string { | ||||
| 	return strings.ReplaceAll(s, "%", "*") | ||||
| } | ||||
| 
 | ||||
| func setupAuthDB(db *sql.DB) error { | ||||
| 	// If 'schemaVersion' table does not exist, this must be a new database | ||||
| 	rowsSV, err := db.Query(selectSchemaVersionQuery) | ||||
| 	if err != nil { | ||||
| 		return setupNewAuthDB(db) | ||||
| 	} | ||||
| 	defer rowsSV.Close() | ||||
| 
 | ||||
| 	// If 'schemaVersion' table exists, read version and potentially upgrade | ||||
| 	schemaVersion := 0 | ||||
| 	if !rowsSV.Next() { | ||||
| 		return errors.New("cannot determine schema version: database file may be corrupt") | ||||
| 	} | ||||
| 	if err := rowsSV.Scan(&schemaVersion); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	rowsSV.Close() | ||||
| 
 | ||||
| 	// Do migrations | ||||
| 	if schemaVersion == currentSchemaVersion { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion) | ||||
| } | ||||
| 
 | ||||
| func setupNewAuthDB(db *sql.DB) error { | ||||
| 	if _, err := db.Exec(createAuthTablesQueries); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										243
									
								
								auth/auth_sqlite_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,243 @@ | |||
| package auth_test | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"heckel.io/ntfy/auth" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources | ||||
| 
 | ||||
| func TestSQLiteAuth_FullScenario_Default_DenyAll(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin)) | ||||
| 	require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "readme", true, false)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair! | ||||
| 	require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false)) | ||||
| 	require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true)) | ||||
| 	require.Nil(t, a.AllowAccess(auth.Everyone, "up*", false, true)) // Everyone can write to /up* | ||||
| 
 | ||||
| 	phil, err := a.Authenticate("phil", "phil") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "phil", phil.Name) | ||||
| 	require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) | ||||
| 	require.Equal(t, auth.RoleAdmin, phil.Role) | ||||
| 	require.Equal(t, []auth.Grant{}, phil.Grants) | ||||
| 
 | ||||
| 	ben, err := a.Authenticate("ben", "ben") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "ben", ben.Name) | ||||
| 	require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) | ||||
| 	require.Equal(t, auth.RoleUser, ben.Role) | ||||
| 	require.Equal(t, []auth.Grant{ | ||||
| 		{"mytopic", true, true}, | ||||
| 		{"readme", true, false}, | ||||
| 		{"writeme", false, true}, | ||||
| 		{"everyonewrite", false, false}, | ||||
| 	}, ben.Grants) | ||||
| 
 | ||||
| 	notben, err := a.Authenticate("ben", "this is wrong") | ||||
| 	require.Nil(t, notben) | ||||
| 	require.Equal(t, auth.ErrUnauthenticated, err) | ||||
| 
 | ||||
| 	// Admin can do everything | ||||
| 	require.Nil(t, a.Authorize(phil, "sometopic", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(phil, "mytopic", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(phil, "readme", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(phil, "writeme", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(phil, "announcements", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(phil, "everyonewrite", auth.PermissionWrite)) | ||||
| 
 | ||||
| 	// User cannot do everything | ||||
| 	require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionWrite)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "writeme", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(ben, "announcements", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "announcements", auth.PermissionWrite)) | ||||
| 
 | ||||
| 	// Everyone else can do barely anything | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionWrite)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionWrite)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionWrite)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionRead)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionWrite)) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "announcements", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(nil, "announcements", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(nil, "up1234", auth.PermissionWrite)) // Wildcard permission | ||||
| 	require.Nil(t, a.Authorize(nil, "up5678", auth.PermissionWrite)) | ||||
| } | ||||
| 
 | ||||
| func TestSQLiteAuth_AddUser_Invalid(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	require.Equal(t, auth.ErrInvalidArgument, a.AddUser("  invalid  ", "pass", auth.RoleAdmin)) | ||||
| 	require.Equal(t, auth.ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role")) | ||||
| } | ||||
| 
 | ||||
| func TestSQLiteAuth_AddUser_Timing(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	start := time.Now().UnixMilli() | ||||
| 	require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin)) | ||||
| 	require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) | ||||
| } | ||||
| 
 | ||||
| func TestSQLiteAuth_Authenticate_Timing(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin)) | ||||
| 
 | ||||
| 	// Timing a correct attempt | ||||
| 	start := time.Now().UnixMilli() | ||||
| 	_, err := a.Authenticate("user", "pass") | ||||
| 	require.Nil(t, err) | ||||
| 	require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) | ||||
| 
 | ||||
| 	// Timing an incorrect attempt | ||||
| 	start = time.Now().UnixMilli() | ||||
| 	_, err = a.Authenticate("user", "INCORRECT") | ||||
| 	require.Equal(t, auth.ErrUnauthenticated, err) | ||||
| 	require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) | ||||
| 
 | ||||
| 	// Timing a non-existing user attempt | ||||
| 	start = time.Now().UnixMilli() | ||||
| 	_, err = a.Authenticate("DOES-NOT-EXIST", "hithere") | ||||
| 	require.Equal(t, auth.ErrUnauthenticated, err) | ||||
| 	require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) | ||||
| } | ||||
| 
 | ||||
| func TestSQLiteAuth_UserManagement(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin)) | ||||
| 	require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "readme", true, false)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair! | ||||
| 	require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false)) | ||||
| 	require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true)) | ||||
| 
 | ||||
| 	// Query user details | ||||
| 	phil, err := a.User("phil") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "phil", phil.Name) | ||||
| 	require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) | ||||
| 	require.Equal(t, auth.RoleAdmin, phil.Role) | ||||
| 	require.Equal(t, []auth.Grant{}, phil.Grants) | ||||
| 
 | ||||
| 	ben, err := a.User("ben") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "ben", ben.Name) | ||||
| 	require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) | ||||
| 	require.Equal(t, auth.RoleUser, ben.Role) | ||||
| 	require.Equal(t, []auth.Grant{ | ||||
| 		{"mytopic", true, true}, | ||||
| 		{"readme", true, false}, | ||||
| 		{"writeme", false, true}, | ||||
| 		{"everyonewrite", false, false}, | ||||
| 	}, ben.Grants) | ||||
| 
 | ||||
| 	everyone, err := a.User(auth.Everyone) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "*", everyone.Name) | ||||
| 	require.Equal(t, "", everyone.Hash) | ||||
| 	require.Equal(t, auth.RoleAnonymous, everyone.Role) | ||||
| 	require.Equal(t, []auth.Grant{ | ||||
| 		{"announcements", true, false}, | ||||
| 		{"everyonewrite", true, true}, | ||||
| 	}, everyone.Grants) | ||||
| 
 | ||||
| 	// Ben: Before revoking | ||||
| 	require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "readme", true, false)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) | ||||
| 	require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite)) | ||||
| 	require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) | ||||
| 	require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) | ||||
| 
 | ||||
| 	// Revoke access for "ben" to "mytopic", then check again | ||||
| 	require.Nil(t, a.ResetAccess("ben", "mytopic")) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionWrite)) // Revoked | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionRead))  // Revoked | ||||
| 	require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))                           // Unchanged | ||||
| 	require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))                         // Unchanged | ||||
| 
 | ||||
| 	// Revoke rest of the access | ||||
| 	require.Nil(t, a.ResetAccess("ben", "")) | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionRead))    // Revoked | ||||
| 	require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "wrtiteme", auth.PermissionWrite)) // Revoked | ||||
| 
 | ||||
| 	// User list | ||||
| 	users, err := a.Users() | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, 3, len(users)) | ||||
| 	require.Equal(t, "phil", users[0].Name) | ||||
| 	require.Equal(t, "ben", users[1].Name) | ||||
| 	require.Equal(t, "*", users[2].Name) | ||||
| 
 | ||||
| 	// Remove user | ||||
| 	require.Nil(t, a.RemoveUser("ben")) | ||||
| 	_, err = a.User("ben") | ||||
| 	require.Equal(t, auth.ErrNotFound, err) | ||||
| 
 | ||||
| 	users, err = a.Users() | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, 2, len(users)) | ||||
| 	require.Equal(t, "phil", users[0].Name) | ||||
| 	require.Equal(t, "*", users[1].Name) | ||||
| } | ||||
| 
 | ||||
| func TestSQLiteAuth_ChangePassword(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin)) | ||||
| 
 | ||||
| 	_, err := a.Authenticate("phil", "phil") | ||||
| 	require.Nil(t, err) | ||||
| 
 | ||||
| 	require.Nil(t, a.ChangePassword("phil", "newpass")) | ||||
| 	_, err = a.Authenticate("phil", "phil") | ||||
| 	require.Equal(t, auth.ErrUnauthenticated, err) | ||||
| 	_, err = a.Authenticate("phil", "newpass") | ||||
| 	require.Nil(t, err) | ||||
| } | ||||
| 
 | ||||
| func TestSQLiteAuth_ChangeRole(t *testing.T) { | ||||
| 	a := newTestAuth(t, false, false) | ||||
| 	require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) | ||||
| 	require.Nil(t, a.AllowAccess("ben", "readme", true, false)) | ||||
| 
 | ||||
| 	ben, err := a.User("ben") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, auth.RoleUser, ben.Role) | ||||
| 	require.Equal(t, 2, len(ben.Grants)) | ||||
| 
 | ||||
| 	require.Nil(t, a.ChangeRole("ben", auth.RoleAdmin)) | ||||
| 
 | ||||
| 	ben, err = a.User("ben") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, auth.RoleAdmin, ben.Role) | ||||
| 	require.Equal(t, 0, len(ben.Grants)) | ||||
| } | ||||
| 
 | ||||
| func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth { | ||||
| 	filename := filepath.Join(t.TempDir(), "user.db") | ||||
| 	a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite) | ||||
| 	require.Nil(t, err) | ||||
| 	return a | ||||
| } | ||||
							
								
								
									
										284
									
								
								client/client.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,284 @@ | |||
| // Package client provides a ntfy client to publish and subscribe to topics | ||||
| package client | ||||
| 
 | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"io" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| // Event type constants | ||||
| const ( | ||||
| 	MessageEvent     = "message" | ||||
| 	KeepaliveEvent   = "keepalive" | ||||
| 	OpenEvent        = "open" | ||||
| 	PollRequestEvent = "poll_request" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	maxResponseBytes = 4096 | ||||
| ) | ||||
| 
 | ||||
| // Client is the ntfy client that can be used to publish and subscribe to ntfy topics | ||||
| type Client struct { | ||||
| 	Messages      chan *Message | ||||
| 	config        *Config | ||||
| 	subscriptions map[string]*subscription | ||||
| 	mu            sync.Mutex | ||||
| } | ||||
| 
 | ||||
| // Message is a struct that represents a ntfy message | ||||
| type Message struct { // TODO combine with server.message | ||||
| 	ID         string | ||||
| 	Event      string | ||||
| 	Time       int64 | ||||
| 	Topic      string | ||||
| 	Message    string | ||||
| 	Title      string | ||||
| 	Priority   int | ||||
| 	Tags       []string | ||||
| 	Click      string | ||||
| 	Attachment *Attachment | ||||
| 
 | ||||
| 	// Additional fields | ||||
| 	TopicURL       string | ||||
| 	SubscriptionID string | ||||
| 	Raw            string | ||||
| } | ||||
| 
 | ||||
| // Attachment represents a message attachment | ||||
| type Attachment struct { | ||||
| 	Name    string `json:"name"` | ||||
| 	Type    string `json:"type,omitempty"` | ||||
| 	Size    int64  `json:"size,omitempty"` | ||||
| 	Expires int64  `json:"expires,omitempty"` | ||||
| 	URL     string `json:"url"` | ||||
| 	Owner   string `json:"-"` // IP address of uploader, used for rate limiting | ||||
| } | ||||
| 
 | ||||
| type subscription struct { | ||||
| 	ID       string | ||||
| 	topicURL string | ||||
| 	cancel   context.CancelFunc | ||||
| } | ||||
| 
 | ||||
| // New creates a new Client using a given Config | ||||
| func New(config *Config) *Client { | ||||
| 	return &Client{ | ||||
| 		Messages:      make(chan *Message, 50), // Allow reading a few messages | ||||
| 		config:        config, | ||||
| 		subscriptions: make(map[string]*subscription), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Publish sends a message to a specific topic, optionally using options. | ||||
| // See PublishReader for details. | ||||
| func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) { | ||||
| 	return c.PublishReader(topic, strings.NewReader(message), options...) | ||||
| } | ||||
| 
 | ||||
| // PublishReader sends a message to a specific topic, optionally using options. | ||||
| // | ||||
| // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// | ||||
| // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the | ||||
| // config (e.g. mytopic -> https://ntfy.sh/mytopic). | ||||
| // | ||||
| // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, | ||||
| // WithNoFirebase, and the generic WithHeader. | ||||
| func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { | ||||
| 	topicURL := c.expandTopicURL(topic) | ||||
| 	req, _ := http.NewRequest("POST", topicURL, body) | ||||
| 	for _, option := range options { | ||||
| 		if err := option(req); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 	} | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 	b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return nil, errors.New(strings.TrimSpace(string(b))) | ||||
| 	} | ||||
| 	m, err := toMessage(string(b), topicURL, "") | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return m, nil | ||||
| } | ||||
| 
 | ||||
| // Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for | ||||
| // messages and does not subscribe to messages that arrive after this call. | ||||
| // | ||||
| // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// | ||||
| // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the | ||||
| // config (e.g. mytopic -> https://ntfy.sh/mytopic). | ||||
| // | ||||
| // By default, all messages will be returned, but you can change this behavior using a SubscribeOption. | ||||
| // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. | ||||
| func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { | ||||
| 	ctx := context.Background() | ||||
| 	messages := make([]*Message, 0) | ||||
| 	msgChan := make(chan *Message) | ||||
| 	errChan := make(chan error) | ||||
| 	topicURL := c.expandTopicURL(topic) | ||||
| 	options = append(options, WithPoll()) | ||||
| 	go func() { | ||||
| 		err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) | ||||
| 		close(msgChan) | ||||
| 		errChan <- err | ||||
| 	}() | ||||
| 	for m := range msgChan { | ||||
| 		messages = append(messages, m) | ||||
| 	} | ||||
| 	return messages, <-errChan | ||||
| } | ||||
| 
 | ||||
| // Subscribe subscribes to a topic to listen for newly incoming messages. The method starts a connection in the | ||||
| // background and returns new messages via the Messages channel. | ||||
| // | ||||
| // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// | ||||
| // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the | ||||
| // config (e.g. mytopic -> https://ntfy.sh/mytopic). | ||||
| // | ||||
| // By default, only new messages will be returned, but you can change this behavior using a SubscribeOption. | ||||
| // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. | ||||
| // | ||||
| // The method returns a unique subscriptionID that can be used in Unsubscribe. | ||||
| // | ||||
| // Example: | ||||
| //   c := client.New(client.NewConfig()) | ||||
| //   subscriptionID := c.Subscribe("mytopic") | ||||
| //   for m := range c.Messages { | ||||
| //     fmt.Printf("New message: %s", m.Message) | ||||
| //   } | ||||
| func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
| 	subscriptionID := util.RandomString(10) | ||||
| 	topicURL := c.expandTopicURL(topic) | ||||
| 	ctx, cancel := context.WithCancel(context.Background()) | ||||
| 	c.subscriptions[subscriptionID] = &subscription{ | ||||
| 		ID:       subscriptionID, | ||||
| 		topicURL: topicURL, | ||||
| 		cancel:   cancel, | ||||
| 	} | ||||
| 	go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) | ||||
| 	return subscriptionID | ||||
| } | ||||
| 
 | ||||
| // Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique | ||||
| // subscriptionID returned in Subscribe. | ||||
| func (c *Client) Unsubscribe(subscriptionID string) { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
| 	sub, ok := c.subscriptions[subscriptionID] | ||||
| 	if !ok { | ||||
| 		return | ||||
| 	} | ||||
| 	delete(c.subscriptions, subscriptionID) | ||||
| 	sub.cancel() | ||||
| } | ||||
| 
 | ||||
| // UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe. | ||||
| // If there are multiple subscriptions matching the topic, all of them are unsubscribed from. | ||||
| // | ||||
| // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// | ||||
| // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the | ||||
| // config (e.g. mytopic -> https://ntfy.sh/mytopic). | ||||
| func (c *Client) UnsubscribeAll(topic string) { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
| 	topicURL := c.expandTopicURL(topic) | ||||
| 	for _, sub := range c.subscriptions { | ||||
| 		if sub.topicURL == topicURL { | ||||
| 			delete(c.subscriptions, sub.ID) | ||||
| 			sub.cancel() | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (c *Client) expandTopicURL(topic string) string { | ||||
| 	if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { | ||||
| 		return topic | ||||
| 	} else if strings.Contains(topic, "/") { | ||||
| 		return fmt.Sprintf("https://%s", topic) | ||||
| 	} | ||||
| 	return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic) | ||||
| } | ||||
| 
 | ||||
| func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { | ||||
| 	for { | ||||
| 		// TODO The retry logic is crude and may lose messages. It should record the last message like the | ||||
| 		//      Android client, use since=, and do incremental backoff too | ||||
| 		if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { | ||||
| 			log.Printf("Connection to %s failed: %s", topicURL, err.Error()) | ||||
| 		} | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			log.Printf("Connection to %s exited", topicURL) | ||||
| 			return | ||||
| 		case <-time.After(10 * time.Second): // TODO Add incremental backoff | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { | ||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	for _, option := range options { | ||||
| 		if err := option(req); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		return errors.New(strings.TrimSpace(string(b))) | ||||
| 	} | ||||
| 	scanner := bufio.NewScanner(resp.Body) | ||||
| 	for scanner.Scan() { | ||||
| 		m, err := toMessage(scanner.Text(), topicURL, subscriptionID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if m.Event == MessageEvent { | ||||
| 			msgChan <- m | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func toMessage(s, topicURL, subscriptionID string) (*Message, error) { | ||||
| 	var m *Message | ||||
| 	if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	m.TopicURL = topicURL | ||||
| 	m.SubscriptionID = subscriptionID | ||||
| 	m.Raw = s | ||||
| 	return m, nil | ||||
| } | ||||
							
								
								
									
										40
									
								
								client/client.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,40 @@ | |||
| # ntfy client config file | ||||
| 
 | ||||
| # Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands. | ||||
| # If you self-host a ntfy server, you'll likely want to change this. | ||||
| # | ||||
| # default-host: https://ntfy.sh | ||||
| 
 | ||||
| # Subscriptions to topics and their actions. This option is primarily used by the systemd service, | ||||
| # or if you cann "ntfy subscribe --from-config" directly. | ||||
| # | ||||
| # Example: | ||||
| #     subscribe: | ||||
| #       - topic: mytopic | ||||
| #         command: /usr/local/bin/mytopic-triggered.sh | ||||
| #       - topic: myserver.com/anothertopic | ||||
| #         command: 'echo "$message"' | ||||
| #         if: | ||||
| #             priority: high,urgent | ||||
| #       - topic: secret | ||||
| #         command: 'notify-send "$m"' | ||||
| #         user: phill | ||||
| #         password: mypass | ||||
| # | ||||
| # Variables: | ||||
| #     Variable        Aliases               Description | ||||
| #     --------------- --------------------- ----------------------------------- | ||||
| #     $NTFY_ID        $id                   Unique message ID | ||||
| #     $NTFY_TIME      $time                 Unix timestamp of the message delivery | ||||
| #     $NTFY_TOPIC     $topic                Topic name | ||||
| #     $NTFY_MESSAGE   $message, $m          Message body | ||||
| #     $NTFY_TITLE     $title, $t            Message title | ||||
| #     $NTFY_PRIORITY  $priority, $prio, $p  Message priority (1=min, 5=max) | ||||
| #     $NTFY_TAGS      $tags, $tag, $ta      Message tags (comma separated list) | ||||
| #     $NTFY_RAW       $raw                  Raw JSON message | ||||
| # | ||||
| # Filters ('if:'): | ||||
| #     You can filter 'message', 'title', 'priority' (comma-separated list, logical OR) | ||||
| #     and 'tags' (comma-separated list, logical AND). See https://ntfy.sh/docs/subscribe/api/#filter-messages. | ||||
| # | ||||
| # subscribe: | ||||
							
								
								
									
										110
									
								
								client/client_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,110 @@ | |||
| package client_test | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"heckel.io/ntfy/client" | ||||
| 	"heckel.io/ntfy/test" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| func TestClient_Publish_Subscribe(t *testing.T) { | ||||
| 	s, port := test.StartServer(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 	c := client.New(newTestConfig(port)) | ||||
| 
 | ||||
| 	subscriptionID := c.Subscribe("mytopic") | ||||
| 	time.Sleep(time.Second) | ||||
| 
 | ||||
| 	msg, err := c.Publish("mytopic", "some message") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "some message", msg.Message) | ||||
| 
 | ||||
| 	msg, err = c.Publish("mytopic", "some other message", | ||||
| 		client.WithTitle("some title"), | ||||
| 		client.WithPriority("high"), | ||||
| 		client.WithTags([]string{"tag1", "tag 2"})) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "some other message", msg.Message) | ||||
| 	require.Equal(t, "some title", msg.Title) | ||||
| 	require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags) | ||||
| 	require.Equal(t, 4, msg.Priority) | ||||
| 
 | ||||
| 	msg, err = c.Publish("mytopic", "some delayed message", | ||||
| 		client.WithDelay("25 hours")) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "some delayed message", msg.Message) | ||||
| 	require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time) | ||||
| 
 | ||||
| 	time.Sleep(200 * time.Millisecond) | ||||
| 
 | ||||
| 	msg = nextMessage(c) | ||||
| 	require.NotNil(t, msg) | ||||
| 	require.Equal(t, "some message", msg.Message) | ||||
| 
 | ||||
| 	msg = nextMessage(c) | ||||
| 	require.NotNil(t, msg) | ||||
| 	require.Equal(t, "some other message", msg.Message) | ||||
| 	require.Equal(t, "some title", msg.Title) | ||||
| 	require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags) | ||||
| 	require.Equal(t, 4, msg.Priority) | ||||
| 
 | ||||
| 	msg = nextMessage(c) | ||||
| 	require.Nil(t, msg) | ||||
| 
 | ||||
| 	c.Unsubscribe(subscriptionID) | ||||
| 	time.Sleep(200 * time.Millisecond) | ||||
| 
 | ||||
| 	msg, err = c.Publish("mytopic", "a message that won't be received") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "a message that won't be received", msg.Message) | ||||
| 
 | ||||
| 	msg = nextMessage(c) | ||||
| 	require.Nil(t, msg) | ||||
| } | ||||
| 
 | ||||
| func TestClient_Publish_Poll(t *testing.T) { | ||||
| 	s, port := test.StartServer(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 	c := client.New(newTestConfig(port)) | ||||
| 
 | ||||
| 	msg, err := c.Publish("mytopic", "some message", client.WithNoFirebase(), client.WithTagsList("tag1,tag2")) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "some message", msg.Message) | ||||
| 	require.Equal(t, []string{"tag1", "tag2"}, msg.Tags) | ||||
| 
 | ||||
| 	msg, err = c.Publish("mytopic", "this won't be cached", client.WithNoCache()) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "this won't be cached", msg.Message) | ||||
| 
 | ||||
| 	msg, err = c.Publish("mytopic", "some delayed message", client.WithDelay("20 min")) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "some delayed message", msg.Message) | ||||
| 
 | ||||
| 	messages, err := c.Poll("mytopic") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, 1, len(messages)) | ||||
| 	require.Equal(t, "some message", messages[0].Message) | ||||
| 
 | ||||
| 	messages, err = c.Poll("mytopic", client.WithScheduled()) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, 2, len(messages)) | ||||
| 	require.Equal(t, "some message", messages[0].Message) | ||||
| 	require.Equal(t, "some delayed message", messages[1].Message) | ||||
| } | ||||
| 
 | ||||
| func newTestConfig(port int) *client.Config { | ||||
| 	c := client.NewConfig() | ||||
| 	c.DefaultHost = fmt.Sprintf("http://127.0.0.1:%d", port) | ||||
| 	return c | ||||
| } | ||||
| 
 | ||||
| func nextMessage(c *client.Client) *client.Message { | ||||
| 	select { | ||||
| 	case m := <-c.Messages: | ||||
| 		return m | ||||
| 	default: | ||||
| 		return nil | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										44
									
								
								client/config.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,44 @@ | |||
| package client | ||||
| 
 | ||||
| import ( | ||||
| 	"gopkg.in/yaml.v2" | ||||
| 	"os" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	// DefaultBaseURL is the base URL used to expand short topic names | ||||
| 	DefaultBaseURL = "https://ntfy.sh" | ||||
| ) | ||||
| 
 | ||||
| // Config is the config struct for a Client | ||||
| type Config struct { | ||||
| 	DefaultHost string `yaml:"default-host"` | ||||
| 	Subscribe   []struct { | ||||
| 		Topic    string            `yaml:"topic"` | ||||
| 		User     string            `yaml:"user"` | ||||
| 		Password string            `yaml:"password"` | ||||
| 		Command  string            `yaml:"command"` | ||||
| 		If       map[string]string `yaml:"if"` | ||||
| 	} `yaml:"subscribe"` | ||||
| } | ||||
| 
 | ||||
| // NewConfig creates a new Config struct for a Client | ||||
| func NewConfig() *Config { | ||||
| 	return &Config{ | ||||
| 		DefaultHost: DefaultBaseURL, | ||||
| 		Subscribe:   nil, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // LoadConfig loads the Client config from a yaml file | ||||
| func LoadConfig(filename string) (*Config, error) { | ||||
| 	b, err := os.ReadFile(filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	c := NewConfig() | ||||
| 	if err := yaml.Unmarshal(b, c); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return c, nil | ||||
| } | ||||
							
								
								
									
										40
									
								
								client/config_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,40 @@ | |||
| package client_test | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"heckel.io/ntfy/client" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
| ) | ||||
| 
 | ||||
| func TestConfig_Load(t *testing.T) { | ||||
| 	filename := filepath.Join(t.TempDir(), "client.yml") | ||||
| 	require.Nil(t, os.WriteFile(filename, []byte(` | ||||
| default-host: http://localhost | ||||
| subscribe: | ||||
|   - topic: no-command-with-auth | ||||
|     user: phil | ||||
|     password: mypass | ||||
|   - topic: echo-this | ||||
|     command: 'echo "Message received: $message"' | ||||
|   - topic: alerts | ||||
|     command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m" | ||||
|     if: | ||||
|             priority: high,urgent | ||||
| `), 0600)) | ||||
| 
 | ||||
| 	conf, err := client.LoadConfig(filename) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, "http://localhost", conf.DefaultHost) | ||||
| 	require.Equal(t, 3, len(conf.Subscribe)) | ||||
| 	require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) | ||||
| 	require.Equal(t, "", conf.Subscribe[0].Command) | ||||
| 	require.Equal(t, "phil", conf.Subscribe[0].User) | ||||
| 	require.Equal(t, "mypass", conf.Subscribe[0].Password) | ||||
| 	require.Equal(t, "echo-this", conf.Subscribe[1].Topic) | ||||
| 	require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command) | ||||
| 	require.Equal(t, "alerts", conf.Subscribe[2].Topic) | ||||
| 	require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command) | ||||
| 	require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"]) | ||||
| } | ||||
							
								
								
									
										12
									
								
								client/ntfy-client.service
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,12 @@ | |||
| [Unit] | ||||
| Description=ntfy client | ||||
| After=network.target | ||||
| 
 | ||||
| [Service] | ||||
| User=ntfy | ||||
| Group=ntfy | ||||
| ExecStart=/usr/bin/ntfy subscribe --config /etc/ntfy/client.yml --from-config | ||||
| Restart=on-failure | ||||
| 
 | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
							
								
								
									
										168
									
								
								client/options.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,168 @@ | |||
| package client | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| // RequestOption is a generic request option that can be added to Client calls | ||||
| type RequestOption = func(r *http.Request) error | ||||
| 
 | ||||
| // PublishOption is an option that can be passed to the Client.Publish call | ||||
| type PublishOption = RequestOption | ||||
| 
 | ||||
| // SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call | ||||
| type SubscribeOption = RequestOption | ||||
| 
 | ||||
| // WithMessage sets the notification message. This is an alternative way to passing the message body. | ||||
| func WithMessage(message string) PublishOption { | ||||
| 	return WithHeader("X-Message", message) | ||||
| } | ||||
| 
 | ||||
| // WithTitle adds a title to a message | ||||
| func WithTitle(title string) PublishOption { | ||||
| 	return WithHeader("X-Title", title) | ||||
| } | ||||
| 
 | ||||
| // WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max), | ||||
| // or the corresponding names (see util.ParsePriority). | ||||
| func WithPriority(priority string) PublishOption { | ||||
| 	return WithHeader("X-Priority", priority) | ||||
| } | ||||
| 
 | ||||
| // WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list | ||||
| // of tags. To use a slice, use WithTags instead | ||||
| func WithTagsList(tags string) PublishOption { | ||||
| 	return WithHeader("X-Tags", tags) | ||||
| } | ||||
| 
 | ||||
| // WithTags adds a list of a tags to a message | ||||
| func WithTags(tags []string) PublishOption { | ||||
| 	return WithTagsList(strings.Join(tags, ",")) | ||||
| } | ||||
| 
 | ||||
| // WithDelay instructs the server to send the message at a later date. The delay parameter can be a | ||||
| // Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery | ||||
| // for details. | ||||
| func WithDelay(delay string) PublishOption { | ||||
| 	return WithHeader("X-Delay", delay) | ||||
| } | ||||
| 
 | ||||
| // WithClick makes the notification action open the given URL as opposed to entering the detail view | ||||
| func WithClick(url string) PublishOption { | ||||
| 	return WithHeader("X-Click", url) | ||||
| } | ||||
| 
 | ||||
| // WithAttach sets a URL that will be used by the client to download an attachment | ||||
| func WithAttach(attach string) PublishOption { | ||||
| 	return WithHeader("X-Attach", attach) | ||||
| } | ||||
| 
 | ||||
| // WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment | ||||
| func WithFilename(filename string) PublishOption { | ||||
| 	return WithHeader("X-Filename", filename) | ||||
| } | ||||
| 
 | ||||
| // WithEmail instructs the server to also send the message to the given e-mail address | ||||
| func WithEmail(email string) PublishOption { | ||||
| 	return WithHeader("X-Email", email) | ||||
| } | ||||
| 
 | ||||
| // WithBasicAuth adds the Authorization header for basic auth to the request | ||||
| func WithBasicAuth(user, pass string) PublishOption { | ||||
| 	return WithHeader("Authorization", util.BasicAuth(user, pass)) | ||||
| } | ||||
| 
 | ||||
| // WithNoCache instructs the server not to cache the message server-side | ||||
| func WithNoCache() PublishOption { | ||||
| 	return WithHeader("X-Cache", "no") | ||||
| } | ||||
| 
 | ||||
| // WithNoFirebase instructs the server not to forward the message to Firebase | ||||
| func WithNoFirebase() PublishOption { | ||||
| 	return WithHeader("X-Firebase", "no") | ||||
| } | ||||
| 
 | ||||
| // WithSince limits the number of messages returned from the server. The parameter since can be a Unix | ||||
| // timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word "all" (see WithSinceAll). | ||||
| func WithSince(since string) SubscribeOption { | ||||
| 	return WithQueryParam("since", since) | ||||
| } | ||||
| 
 | ||||
| // WithSinceAll instructs the server to return all messages for the given topic from the server | ||||
| func WithSinceAll() SubscribeOption { | ||||
| 	return WithSince("all") | ||||
| } | ||||
| 
 | ||||
| // WithSinceDuration instructs the server to return all messages since the given duration ago | ||||
| func WithSinceDuration(since time.Duration) SubscribeOption { | ||||
| 	return WithSinceUnixTime(time.Now().Add(-1 * since).Unix()) | ||||
| } | ||||
| 
 | ||||
| // WithSinceUnixTime instructs the server to return only messages newer or equal to the given timestamp | ||||
| func WithSinceUnixTime(since int64) SubscribeOption { | ||||
| 	return WithSince(fmt.Sprintf("%d", since)) | ||||
| } | ||||
| 
 | ||||
| // WithPoll instructs the server to close the connection after messages have been returned. Don't use this option | ||||
| // directly. Use Client.Poll instead. | ||||
| func WithPoll() SubscribeOption { | ||||
| 	return WithQueryParam("poll", "1") | ||||
| } | ||||
| 
 | ||||
| // WithScheduled instructs the server to also return messages that have not been sent yet, i.e. delayed/scheduled | ||||
| // messages (see WithDelay). The messages will have a future date. | ||||
| func WithScheduled() SubscribeOption { | ||||
| 	return WithQueryParam("scheduled", "1") | ||||
| } | ||||
| 
 | ||||
| // WithFilter is a generic subscribe option meant to be used to filter for certain messages only | ||||
| func WithFilter(param, value string) SubscribeOption { | ||||
| 	return WithQueryParam(param, value) | ||||
| } | ||||
| 
 | ||||
| // WithMessageFilter instructs the server to only return messages that match the exact message | ||||
| func WithMessageFilter(message string) SubscribeOption { | ||||
| 	return WithQueryParam("message", message) | ||||
| } | ||||
| 
 | ||||
| // WithTitleFilter instructs the server to only return messages with a title that match the exact string | ||||
| func WithTitleFilter(title string) SubscribeOption { | ||||
| 	return WithQueryParam("title", title) | ||||
| } | ||||
| 
 | ||||
| // WithPriorityFilter instructs the server to only return messages with the matching priority. Not that messages | ||||
| // without priority also implicitly match priority 3. | ||||
| func WithPriorityFilter(priority int) SubscribeOption { | ||||
| 	return WithQueryParam("priority", fmt.Sprintf("%d", priority)) | ||||
| } | ||||
| 
 | ||||
| // WithTagsFilter instructs the server to only return messages that contain all of the given tags | ||||
| func WithTagsFilter(tags []string) SubscribeOption { | ||||
| 	return WithQueryParam("tags", strings.Join(tags, ",")) | ||||
| } | ||||
| 
 | ||||
| // WithHeader is a generic option to add headers to a request | ||||
| func WithHeader(header, value string) RequestOption { | ||||
| 	return func(r *http.Request) error { | ||||
| 		if value != "" { | ||||
| 			r.Header.Set(header, value) | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // WithQueryParam is a generic option to add query parameters to a request | ||||
| func WithQueryParam(param, value string) RequestOption { | ||||
| 	return func(r *http.Request) error { | ||||
| 		if value != "" { | ||||
| 			q := r.URL.Query() | ||||
| 			q.Add(param, value) | ||||
| 			r.URL.RawQuery = q.Encode() | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										212
									
								
								cmd/access.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,212 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"heckel.io/ntfy/auth" | ||||
| 	"heckel.io/ntfy/util" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	userEveryone = "everyone" | ||||
| ) | ||||
| 
 | ||||
| var flagsAccess = append( | ||||
| 	userCommandFlags(), | ||||
| 	&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, | ||||
| ) | ||||
| 
 | ||||
| var cmdAccess = &cli.Command{ | ||||
| 	Name:      "access", | ||||
| 	Usage:     "Grant/revoke access to a topic, or show access", | ||||
| 	UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", | ||||
| 	Flags:     flagsAccess, | ||||
| 	Before:    initConfigFileInputSource("config", flagsAccess), | ||||
| 	Action:    execUserAccess, | ||||
| 	Category:  categoryServer, | ||||
| 	Description: `Manage the access control list for the ntfy server. | ||||
| 
 | ||||
| This is a server-only command. It directly manages the user.db as defined in the server config | ||||
| file server.yml. The command only works if 'auth-file' is properly defined. Please also refer | ||||
| to the related command 'ntfy user'. | ||||
| 
 | ||||
| The command allows you to show the access control list, as well as change it, depending on how | ||||
| it is called. | ||||
| 
 | ||||
| Usage: | ||||
|   ntfy access                            # Shows access control list (alias: 'ntfy user list') | ||||
|   ntfy access USERNAME                   # Shows access control entries for USERNAME | ||||
|   ntfy access USERNAME TOPIC PERMISSION  # Allow/deny access for USERNAME to TOPIC | ||||
| 
 | ||||
| Arguments: | ||||
|   USERNAME     an existing user, as created with 'ntfy user add', or "everyone"/"*" | ||||
|                to define access rules for anonymous/unauthenticated clients | ||||
|   TOPIC        name of a topic with optional wildcards, e.g. "mytopic*" | ||||
|   PERMISSION   one of the following: | ||||
|                - read-write (alias: rw)  | ||||
|                - read-only (aliases: read, ro) | ||||
|                - write-only (aliases: write, wo) | ||||
|                - deny (alias: none) | ||||
| 
 | ||||
| Examples: | ||||
|   ntfy access                        # Shows access control list (alias: 'ntfy user list') | ||||
|   ntfy access phil                   # Shows access for user phil | ||||
|   ntfy access phil mytopic rw        # Allow read-write access to mytopic for user phil | ||||
|   ntfy access everyone mytopic rw    # Allow anonymous read-write access to mytopic | ||||
|   ntfy access everyone "up*" write   # Allow anonymous write-only access to topics "up..."  | ||||
|   ntfy access --reset                # Reset entire access control list | ||||
|   ntfy access --reset phil           # Reset all access for user phil | ||||
|   ntfy access --reset phil mytopic   # Reset access for user phil and topic mytopic | ||||
| `, | ||||
| } | ||||
| 
 | ||||
| func execUserAccess(c *cli.Context) error { | ||||
| 	if c.NArg() > 3 { | ||||
| 		return errors.New("too many arguments, please check 'ntfy access --help' for usage details") | ||||
| 	} | ||||
| 	manager, err := createAuthManager(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	username := c.Args().Get(0) | ||||
| 	if username == userEveryone { | ||||
| 		username = auth.Everyone | ||||
| 	} | ||||
| 	topic := c.Args().Get(1) | ||||
| 	perms := c.Args().Get(2) | ||||
| 	reset := c.Bool("reset") | ||||
| 	if reset { | ||||
| 		if perms != "" { | ||||
| 			return errors.New("too many arguments, please check 'ntfy access --help' for usage details") | ||||
| 		} | ||||
| 		return resetAccess(c, manager, username, topic) | ||||
| 	} else if perms == "" { | ||||
| 		if topic != "" { | ||||
| 			return errors.New("invalid syntax, please check 'ntfy access --help' for usage details") | ||||
| 		} | ||||
| 		return showAccess(c, manager, username) | ||||
| 	} | ||||
| 	return changeAccess(c, manager, username, topic, perms) | ||||
| } | ||||
| 
 | ||||
| func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error { | ||||
| 	if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) { | ||||
| 		return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)") | ||||
| 	} | ||||
| 	read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms) | ||||
| 	write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms) | ||||
| 	user, err := manager.User(username) | ||||
| 	if err == auth.ErrNotFound { | ||||
| 		return fmt.Errorf("user %s does not exist", username) | ||||
| 	} else if user.Role == auth.RoleAdmin { | ||||
| 		return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) | ||||
| 	} | ||||
| 	if err := manager.AllowAccess(username, topic, read, write); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if read && write { | ||||
| 		fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic) | ||||
| 	} else if read { | ||||
| 		fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic) | ||||
| 	} else if write { | ||||
| 		fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic) | ||||
| 	} else { | ||||
| 		fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic) | ||||
| 	} | ||||
| 	return showUserAccess(c, manager, username) | ||||
| } | ||||
| 
 | ||||
| func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) error { | ||||
| 	if username == "" { | ||||
| 		return resetAllAccess(c, manager) | ||||
| 	} else if topic == "" { | ||||
| 		return resetUserAccess(c, manager, username) | ||||
| 	} | ||||
| 	return resetUserTopicAccess(c, manager, username, topic) | ||||
| } | ||||
| 
 | ||||
| func resetAllAccess(c *cli.Context, manager auth.Manager) error { | ||||
| 	if err := manager.ResetAccess("", ""); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintln(c.App.ErrWriter, "reset access for all users") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func resetUserAccess(c *cli.Context, manager auth.Manager, username string) error { | ||||
| 	if err := manager.ResetAccess(username, ""); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username) | ||||
| 	return showUserAccess(c, manager, username) | ||||
| } | ||||
| 
 | ||||
| func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, topic string) error { | ||||
| 	if err := manager.ResetAccess(username, topic); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic) | ||||
| 	return showUserAccess(c, manager, username) | ||||
| } | ||||
| 
 | ||||
| func showAccess(c *cli.Context, manager auth.Manager, username string) error { | ||||
| 	if username == "" { | ||||
| 		return showAllAccess(c, manager) | ||||
| 	} | ||||
| 	return showUserAccess(c, manager, username) | ||||
| } | ||||
| 
 | ||||
| func showAllAccess(c *cli.Context, manager auth.Manager) error { | ||||
| 	users, err := manager.Users() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return showUsers(c, manager, users) | ||||
| } | ||||
| 
 | ||||
| func showUserAccess(c *cli.Context, manager auth.Manager, username string) error { | ||||
| 	users, err := manager.User(username) | ||||
| 	if err == auth.ErrNotFound { | ||||
| 		return fmt.Errorf("user %s does not exist", username) | ||||
| 	} else if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return showUsers(c, manager, []*auth.User{users}) | ||||
| } | ||||
| 
 | ||||
| func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error { | ||||
| 	for _, user := range users { | ||||
| 		fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", user.Name, user.Role) | ||||
| 		if user.Role == auth.RoleAdmin { | ||||
| 			fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") | ||||
| 		} else if len(user.Grants) > 0 { | ||||
| 			for _, grant := range user.Grants { | ||||
| 				if grant.AllowRead && grant.AllowWrite { | ||||
| 					fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) | ||||
| 				} else if grant.AllowRead { | ||||
| 					fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern) | ||||
| 				} else if grant.AllowWrite { | ||||
| 					fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern) | ||||
| 				} else { | ||||
| 					fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern) | ||||
| 				} | ||||
| 			} | ||||
| 		} else { | ||||
| 			fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n") | ||||
| 		} | ||||
| 		if user.Name == auth.Everyone { | ||||
| 			defaultRead, defaultWrite := manager.DefaultAccess() | ||||
| 			if defaultRead && defaultWrite { | ||||
| 				fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)") | ||||
| 			} else if defaultRead { | ||||
| 				fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)") | ||||
| 			} else if defaultWrite { | ||||
| 				fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)") | ||||
| 			} else { | ||||
| 				fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)") | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										87
									
								
								cmd/access_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,87 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"heckel.io/ntfy/server" | ||||
| 	"heckel.io/ntfy/test" | ||||
| 	"testing" | ||||
| ) | ||||
| 
 | ||||
| func TestCLI_Access_Show(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	app, _, _, stderr := newTestApp() | ||||
| 	require.Nil(t, runAccessCommand(app, conf)) | ||||
| 	require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_Access_Grant_And_Publish(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	app, stdin, _, _ := newTestApp() | ||||
| 	stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "ben")) | ||||
| 	require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw")) | ||||
| 	require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read")) | ||||
| 	require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read")) | ||||
| 
 | ||||
| 	app, _, _, stderr := newTestApp() | ||||
| 	require.Nil(t, runAccessCommand(app, conf)) | ||||
| 	expected := `user phil (admin) | ||||
| - read-write access to all topics (admin role) | ||||
| user ben (user) | ||||
| - read-write access to topic announcements | ||||
| - read-only access to topic sometopic | ||||
| user * (anonymous) | ||||
| - read-only access to topic announcements | ||||
| - no access to any (other) topics (server config) | ||||
| ` | ||||
| 	require.Equal(t, expected, stderr.String()) | ||||
| 
 | ||||
| 	// See if access permissions match | ||||
| 	app, _, _, _ = newTestApp() | ||||
| 	require.Error(t, app.Run([]string{ | ||||
| 		"ntfy", | ||||
| 		"publish", | ||||
| 		fmt.Sprintf("http://127.0.0.1:%d/announcements", port), | ||||
| 	})) | ||||
| 	require.Nil(t, app.Run([]string{ | ||||
| 		"ntfy", | ||||
| 		"publish", | ||||
| 		"-u", "ben:benpass", | ||||
| 		fmt.Sprintf("http://127.0.0.1:%d/announcements", port), | ||||
| 	})) | ||||
| 	require.Nil(t, app.Run([]string{ | ||||
| 		"ntfy", | ||||
| 		"publish", | ||||
| 		"-u", "phil:philpass", | ||||
| 		fmt.Sprintf("http://127.0.0.1:%d/announcements", port), | ||||
| 	})) | ||||
| 	require.Nil(t, app.Run([]string{ | ||||
| 		"ntfy", | ||||
| 		"subscribe", | ||||
| 		"--poll", | ||||
| 		fmt.Sprintf("http://127.0.0.1:%d/announcements", port), | ||||
| 	})) | ||||
| 	require.Error(t, app.Run([]string{ | ||||
| 		"ntfy", | ||||
| 		"subscribe", | ||||
| 		"--poll", | ||||
| 		fmt.Sprintf("http://127.0.0.1:%d/something-else", port), | ||||
| 	})) | ||||
| } | ||||
| 
 | ||||
| func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error { | ||||
| 	userArgs := []string{ | ||||
| 		"ntfy", | ||||
| 		"access", | ||||
| 		"--auth-file=" + conf.AuthFile, | ||||
| 		"--auth-default-access=" + confToDefaultAccess(conf), | ||||
| 	} | ||||
| 	return app.Run(append(userArgs, args...)) | ||||
| } | ||||
							
								
								
									
										105
									
								
								cmd/app.go
									
										
									
									
									
								
							
							
						
						|  | @ -2,112 +2,45 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"github.com/urfave/cli/v2/altsrc" | ||||
| 	"heckel.io/ntfy/config" | ||||
| 	"heckel.io/ntfy/server" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	defaultClientRootConfigFile = "/etc/ntfy/client.yml" | ||||
| 	defaultClientUserConfigFile = "~/.config/ntfy/client.yml" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	categoryClient = "Client commands" | ||||
| 	categoryServer = "Server commands" | ||||
| ) | ||||
| 
 | ||||
| // New creates a new CLI application | ||||
| func New() *cli.App { | ||||
| 	flags := []cli.Flag{ | ||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/config.yml", DefaultText: "/etc/ntfy/config.yml", Usage: "config file"}, | ||||
| 		altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: config.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: "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: config.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), | ||||
| 		altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), | ||||
| 		altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: config.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), | ||||
| 		altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: config.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}), | ||||
| 		altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"V"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: config.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), | ||||
| 		altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"B"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: config.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), | ||||
| 		altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"R"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: config.DefaultVisitorRequestLimitReplenish, 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)"}), | ||||
| 	} | ||||
| 	return &cli.App{ | ||||
| 		Name:                   "ntfy", | ||||
| 		Usage:                  "Simple pub-sub notification service", | ||||
| 		UsageText:              "ntfy [OPTION..]", | ||||
| 		HideHelp:               true, | ||||
| 		HideVersion:            true, | ||||
| 		EnableBashCompletion:   true, | ||||
| 		UseShortOptionHandling: true, | ||||
| 		Reader:                 os.Stdin, | ||||
| 		Writer:                 os.Stdout, | ||||
| 		ErrWriter:              os.Stderr, | ||||
| 		Action:                 execRun, | ||||
| 		Before:                 initConfigFileInputSource("config", flags), | ||||
| 		Flags:                  flags, | ||||
| 	} | ||||
| } | ||||
| 		Commands: []*cli.Command{ | ||||
| 			// Server commands | ||||
| 			cmdServe, | ||||
| 			cmdUser, | ||||
| 			cmdAccess, | ||||
| 
 | ||||
| func execRun(c *cli.Context) error { | ||||
| 	// Read all the options | ||||
| 	listenHTTP := c.String("listen-http") | ||||
| 	listenHTTPS := c.String("listen-https") | ||||
| 	keyFile := c.String("key-file") | ||||
| 	certFile := c.String("cert-file") | ||||
| 	firebaseKeyFile := c.String("firebase-key-file") | ||||
| 	cacheFile := c.String("cache-file") | ||||
| 	cacheDuration := c.Duration("cache-duration") | ||||
| 	keepaliveInterval := c.Duration("keepalive-interval") | ||||
| 	managerInterval := c.Duration("manager-interval") | ||||
| 	globalTopicLimit := c.Int("global-topic-limit") | ||||
| 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit") | ||||
| 	visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") | ||||
| 	visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") | ||||
| 	behindProxy := c.Bool("behind-proxy") | ||||
| 
 | ||||
| 	// Check values | ||||
| 	if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { | ||||
| 		return errors.New("if set, FCM key file must exist") | ||||
| 	} else if keepaliveInterval < 5*time.Second { | ||||
| 		return errors.New("keepalive interval cannot be lower than five seconds") | ||||
| 	} else if managerInterval < 5*time.Second { | ||||
| 		return errors.New("manager interval cannot be lower than five seconds") | ||||
| 	} else if cacheDuration < managerInterval { | ||||
| 		return errors.New("cache duration cannot be lower than manager interval") | ||||
| 	} else if keyFile != "" && !util.FileExists(keyFile) { | ||||
| 		return errors.New("if set, key file must exist") | ||||
| 	} else if certFile != "" && !util.FileExists(certFile) { | ||||
| 		return errors.New("if set, certificate file must exist") | ||||
| 	} else if listenHTTPS != "" && (keyFile == "" || certFile == "") { | ||||
| 		return errors.New("if listen-https is set, both key-file and cert-file must be set") | ||||
| 			// Client commands | ||||
| 			cmdPublish, | ||||
| 			cmdSubscribe, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	// Run server | ||||
| 	conf := config.New(listenHTTP) | ||||
| 	conf.ListenHTTPS = listenHTTPS | ||||
| 	conf.KeyFile = keyFile | ||||
| 	conf.CertFile = certFile | ||||
| 	conf.FirebaseKeyFile = firebaseKeyFile | ||||
| 	conf.CacheFile = cacheFile | ||||
| 	conf.CacheDuration = cacheDuration | ||||
| 	conf.KeepaliveInterval = keepaliveInterval | ||||
| 	conf.ManagerInterval = managerInterval | ||||
| 	conf.GlobalTopicLimit = globalTopicLimit | ||||
| 	conf.VisitorSubscriptionLimit = visitorSubscriptionLimit | ||||
| 	conf.VisitorRequestLimitBurst = visitorRequestLimitBurst | ||||
| 	conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish | ||||
| 	conf.BehindProxy = behindProxy | ||||
| 	s, err := server.New(conf) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	if err := s.Run(); err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	log.Printf("Exiting.") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks | ||||
|  |  | |||
							
								
								
									
										35
									
								
								cmd/app_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,35 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"heckel.io/ntfy/client" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| ) | ||||
| 
 | ||||
| // This only contains helpers so far | ||||
| 
 | ||||
| func TestMain(m *testing.M) { | ||||
| 	// log.SetOutput(io.Discard) | ||||
| 	os.Exit(m.Run()) | ||||
| } | ||||
| 
 | ||||
| func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { | ||||
| 	var stdin, stdout, stderr bytes.Buffer | ||||
| 	app := New() | ||||
| 	app.Reader = &stdin | ||||
| 	app.Writer = &stdout | ||||
| 	app.ErrWriter = &stderr | ||||
| 	return app, &stdin, &stdout, &stderr | ||||
| } | ||||
| 
 | ||||
| func toMessage(t *testing.T, s string) *client.Message { | ||||
| 	var m *client.Message | ||||
| 	if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	return m | ||||
| } | ||||
							
								
								
									
										178
									
								
								cmd/publish.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,178 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"heckel.io/ntfy/client" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| ) | ||||
| 
 | ||||
| var cmdPublish = &cli.Command{ | ||||
| 	Name:      "publish", | ||||
| 	Aliases:   []string{"pub", "send", "trigger"}, | ||||
| 	Usage:     "Send message via a ntfy server", | ||||
| 	UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n   NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]", | ||||
| 	Action:    execPublish, | ||||
| 	Category:  categoryClient, | ||||
| 	Flags: []cli.Flag{ | ||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, | ||||
| 		&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, | ||||
| 		&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, | ||||
| 		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, | ||||
| 		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, | ||||
| 		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, | ||||
| 		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, | ||||
| 		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, | ||||
| 		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, | ||||
| 		&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, | ||||
| 		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, | ||||
| 		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, | ||||
| 		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, | ||||
| 		&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"}, | ||||
| 		&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"}, | ||||
| 	}, | ||||
| 	Description: `Publish a message to a ntfy server. | ||||
| 
 | ||||
| Examples: | ||||
|   ntfy publish mytopic This is my message                 # Send simple message | ||||
|   ntfy send myserver.com/mytopic "This is my message"     # Send message to different default host | ||||
|   ntfy pub -p high backups "Backups failed"               # Send high priority message | ||||
|   ntfy pub --tags=warning,skull backups "Backups failed"  # Add tags/emojis to message | ||||
|   ntfy pub --delay=10s delayed_topic Laterzz              # Delay message by 10s | ||||
|   ntfy pub --at=8:30am delayed_topic Laterzz              # Send message at 8:30am | ||||
|   ntfy pub -e phil@example.com alerts 'App is down!'      # Also send email to phil@example.com | ||||
|   ntfy pub --click="https://reddit.com" redd 'New msg'    # Opens Reddit when notification is clicked | ||||
|   ntfy pub --attach="http://some.tld/file.zip" files      # Send ZIP archive from URL as attachment | ||||
|   ntfy pub --file=flower.jpg flowers 'Nice!'              # Send image.jpg as attachment | ||||
|   ntfy pub -u phil:mypass secret Psst                     # Publish with username/password | ||||
|   NTFY_USER=phil:mypass ntfy pub secret Psst              # Use env variables to set username/password | ||||
|   NTFY_TOPIC=mytopic ntfy pub -P "some message""          # Use NTFY_TOPIC variable as topic  | ||||
|   cat flower.jpg | ntfy pub --file=- flowers 'Nice!'      # Same as above, send image.jpg as attachment | ||||
|   ntfy trigger mywebhook                                  # Sending without message, useful for webhooks | ||||
|   | ||||
| Please also check out the docs on publishing messages. Especially for the --tags and --delay options,  | ||||
| it has incredibly useful information: https://ntfy.sh/docs/publish/. | ||||
| 
 | ||||
| The default config file for all client commands is /etc/ntfy/client.yml (if root user), | ||||
| or ~/.config/ntfy/client.yml for all other users.`, | ||||
| } | ||||
| 
 | ||||
| func execPublish(c *cli.Context) error { | ||||
| 	conf, err := loadConfig(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	title := c.String("title") | ||||
| 	priority := c.String("priority") | ||||
| 	tags := c.String("tags") | ||||
| 	delay := c.String("delay") | ||||
| 	click := c.String("click") | ||||
| 	attach := c.String("attach") | ||||
| 	filename := c.String("filename") | ||||
| 	file := c.String("file") | ||||
| 	email := c.String("email") | ||||
| 	user := c.String("user") | ||||
| 	noCache := c.Bool("no-cache") | ||||
| 	noFirebase := c.Bool("no-firebase") | ||||
| 	envTopic := c.Bool("env-topic") | ||||
| 	quiet := c.Bool("quiet") | ||||
| 	var topic, message string | ||||
| 	if envTopic { | ||||
| 		topic = os.Getenv("NTFY_TOPIC") | ||||
| 		if c.NArg() > 0 { | ||||
| 			message = strings.Join(c.Args().Slice(), " ") | ||||
| 		} | ||||
| 	} else { | ||||
| 		if c.NArg() < 1 { | ||||
| 			return errors.New("must specify topic, type 'ntfy publish --help' for help") | ||||
| 		} | ||||
| 		topic = c.Args().Get(0) | ||||
| 		if c.NArg() > 1 { | ||||
| 			message = strings.Join(c.Args().Slice()[1:], " ") | ||||
| 		} | ||||
| 	} | ||||
| 	var options []client.PublishOption | ||||
| 	if title != "" { | ||||
| 		options = append(options, client.WithTitle(title)) | ||||
| 	} | ||||
| 	if priority != "" { | ||||
| 		options = append(options, client.WithPriority(priority)) | ||||
| 	} | ||||
| 	if tags != "" { | ||||
| 		options = append(options, client.WithTagsList(tags)) | ||||
| 	} | ||||
| 	if delay != "" { | ||||
| 		options = append(options, client.WithDelay(delay)) | ||||
| 	} | ||||
| 	if click != "" { | ||||
| 		options = append(options, client.WithClick(click)) | ||||
| 	} | ||||
| 	if attach != "" { | ||||
| 		options = append(options, client.WithAttach(attach)) | ||||
| 	} | ||||
| 	if filename != "" { | ||||
| 		options = append(options, client.WithFilename(filename)) | ||||
| 	} | ||||
| 	if email != "" { | ||||
| 		options = append(options, client.WithEmail(email)) | ||||
| 	} | ||||
| 	if noCache { | ||||
| 		options = append(options, client.WithNoCache()) | ||||
| 	} | ||||
| 	if noFirebase { | ||||
| 		options = append(options, client.WithNoFirebase()) | ||||
| 	} | ||||
| 	if user != "" { | ||||
| 		var pass string | ||||
| 		parts := strings.SplitN(user, ":", 2) | ||||
| 		if len(parts) == 2 { | ||||
| 			user = parts[0] | ||||
| 			pass = parts[1] | ||||
| 		} else { | ||||
| 			fmt.Fprint(c.App.ErrWriter, "Enter Password: ") | ||||
| 			p, err := util.ReadPassword(c.App.Reader) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			pass = string(p) | ||||
| 			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) | ||||
| 		} | ||||
| 		options = append(options, client.WithBasicAuth(user, pass)) | ||||
| 	} | ||||
| 	var body io.Reader | ||||
| 	if file == "" { | ||||
| 		body = strings.NewReader(message) | ||||
| 	} else { | ||||
| 		if message != "" { | ||||
| 			options = append(options, client.WithMessage(message)) | ||||
| 		} | ||||
| 		if file == "-" { | ||||
| 			if filename == "" { | ||||
| 				options = append(options, client.WithFilename("stdin")) | ||||
| 			} | ||||
| 			body = c.App.Reader | ||||
| 		} else { | ||||
| 			if filename == "" { | ||||
| 				options = append(options, client.WithFilename(filepath.Base(file))) | ||||
| 			} | ||||
| 			body, err = os.Open(file) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	cl := client.New(conf) | ||||
| 	m, err := cl.PublishReader(topic, body, options...) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if !quiet { | ||||
| 		fmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw)) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										72
									
								
								cmd/publish_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,72 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"heckel.io/ntfy/test" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"testing" | ||||
| ) | ||||
| 
 | ||||
| func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { | ||||
| 	testMessage := util.RandomString(10) | ||||
| 
 | ||||
| 	app, _, _, _ := newTestApp() | ||||
| 	require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) | ||||
| 
 | ||||
| 	app2, _, stdout, _ := newTestApp() | ||||
| 	require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"})) | ||||
| 	require.Contains(t, stdout.String(), testMessage) | ||||
| } | ||||
| 
 | ||||
| func TestCLI_Publish_Subscribe_Poll(t *testing.T) { | ||||
| 	s, port := test.StartServer(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 	topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) | ||||
| 
 | ||||
| 	app, _, stdout, _ := newTestApp() | ||||
| 	require.Nil(t, app.Run([]string{"ntfy", "publish", topic, "some message"})) | ||||
| 	m := toMessage(t, stdout.String()) | ||||
| 	require.Equal(t, "some message", m.Message) | ||||
| 
 | ||||
| 	app2, _, stdout, _ := newTestApp() | ||||
| 	require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", topic})) | ||||
| 	m = toMessage(t, stdout.String()) | ||||
| 	require.Equal(t, "some message", m.Message) | ||||
| } | ||||
| 
 | ||||
| func TestCLI_Publish_All_The_Things(t *testing.T) { | ||||
| 	s, port := test.StartServer(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 	topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) | ||||
| 
 | ||||
| 	app, _, stdout, _ := newTestApp() | ||||
| 	require.Nil(t, app.Run([]string{ | ||||
| 		"ntfy", "publish", | ||||
| 		"--title", "this is a title", | ||||
| 		"--priority", "high", | ||||
| 		"--tags", "tag1,tag2", | ||||
| 		// No --delay, --email | ||||
| 		"--click", "https://ntfy.sh", | ||||
| 		"--attach", "https://f-droid.org/F-Droid.apk", | ||||
| 		"--filename", "fdroid.apk", | ||||
| 		"--no-cache", | ||||
| 		"--no-firebase", | ||||
| 		topic, | ||||
| 		"some message", | ||||
| 	})) | ||||
| 	m := toMessage(t, stdout.String()) | ||||
| 	require.Equal(t, "message", m.Event) | ||||
| 	require.Equal(t, "mytopic", m.Topic) | ||||
| 	require.Equal(t, "some message", m.Message) | ||||
| 	require.Equal(t, "this is a title", m.Title) | ||||
| 	require.Equal(t, 4, m.Priority) | ||||
| 	require.Equal(t, []string{"tag1", "tag2"}, m.Tags) | ||||
| 	require.Equal(t, "https://ntfy.sh", m.Click) | ||||
| 	require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL) | ||||
| 	require.Equal(t, "fdroid.apk", m.Attachment.Name) | ||||
| 	require.Equal(t, int64(0), m.Attachment.Size) | ||||
| 	require.Equal(t, "", m.Attachment.Owner) | ||||
| 	require.Equal(t, int64(0), m.Attachment.Expires) | ||||
| 	require.Equal(t, "", m.Attachment.Type) | ||||
| } | ||||
							
								
								
									
										246
									
								
								cmd/serve.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,246 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"github.com/urfave/cli/v2/altsrc" | ||||
| 	"heckel.io/ntfy/server" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"net" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| var flagsServe = []cli.Flag{ | ||||
| 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), | ||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), | ||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), | ||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), | ||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), | ||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), | ||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), | ||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), | ||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), | ||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), | ||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), | ||||
| 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), | ||||
| } | ||||
| 
 | ||||
| var cmdServe = &cli.Command{ | ||||
| 	Name:      "serve", | ||||
| 	Usage:     "Run the ntfy server", | ||||
| 	UsageText: "ntfy serve [OPTIONS..]", | ||||
| 	Action:    execServe, | ||||
| 	Category:  categoryServer, | ||||
| 	Flags:     flagsServe, | ||||
| 	Before:    initConfigFileInputSource("config", flagsServe), | ||||
| 	Description: `Run the ntfy server and listen for incoming requests | ||||
| 
 | ||||
| The command will load the configuration from /etc/ntfy/server.yml. Config options can  | ||||
| be overridden using the command line options. | ||||
| 
 | ||||
| Examples: | ||||
|   ntfy serve                      # Starts server in the foreground (on port 80) | ||||
|   ntfy serve --listen-http :8080  # Starts server with alternate port`, | ||||
| } | ||||
| 
 | ||||
| func execServe(c *cli.Context) error { | ||||
| 	if c.NArg() > 0 { | ||||
| 		return errors.New("no arguments expected, see 'ntfy serve --help' for help") | ||||
| 	} | ||||
| 
 | ||||
| 	// Read all the options | ||||
| 	baseURL := c.String("base-url") | ||||
| 	listenHTTP := c.String("listen-http") | ||||
| 	listenHTTPS := c.String("listen-https") | ||||
| 	listenUnix := c.String("listen-unix") | ||||
| 	keyFile := c.String("key-file") | ||||
| 	certFile := c.String("cert-file") | ||||
| 	firebaseKeyFile := c.String("firebase-key-file") | ||||
| 	cacheFile := c.String("cache-file") | ||||
| 	cacheDuration := c.Duration("cache-duration") | ||||
| 	authFile := c.String("auth-file") | ||||
| 	authDefaultAccess := c.String("auth-default-access") | ||||
| 	attachmentCacheDir := c.String("attachment-cache-dir") | ||||
| 	attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") | ||||
| 	attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") | ||||
| 	attachmentExpiryDuration := c.Duration("attachment-expiry-duration") | ||||
| 	keepaliveInterval := c.Duration("keepalive-interval") | ||||
| 	managerInterval := c.Duration("manager-interval") | ||||
| 	webRoot := c.String("web-root") | ||||
| 	smtpSenderAddr := c.String("smtp-sender-addr") | ||||
| 	smtpSenderUser := c.String("smtp-sender-user") | ||||
| 	smtpSenderPass := c.String("smtp-sender-pass") | ||||
| 	smtpSenderFrom := c.String("smtp-sender-from") | ||||
| 	smtpServerListen := c.String("smtp-server-listen") | ||||
| 	smtpServerDomain := c.String("smtp-server-domain") | ||||
| 	smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") | ||||
| 	totalTopicLimit := c.Int("global-topic-limit") | ||||
| 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit") | ||||
| 	visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") | ||||
| 	visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") | ||||
| 	visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") | ||||
| 	visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") | ||||
| 	visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",") | ||||
| 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") | ||||
| 	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") | ||||
| 	behindProxy := c.Bool("behind-proxy") | ||||
| 
 | ||||
| 	// Check values | ||||
| 	if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { | ||||
| 		return errors.New("if set, FCM key file must exist") | ||||
| 	} else if keepaliveInterval < 5*time.Second { | ||||
| 		return errors.New("keepalive interval cannot be lower than five seconds") | ||||
| 	} else if managerInterval < 5*time.Second { | ||||
| 		return errors.New("manager interval cannot be lower than five seconds") | ||||
| 	} else if cacheDuration > 0 && cacheDuration < managerInterval { | ||||
| 		return errors.New("cache duration cannot be lower than manager interval") | ||||
| 	} else if keyFile != "" && !util.FileExists(keyFile) { | ||||
| 		return errors.New("if set, key file must exist") | ||||
| 	} else if certFile != "" && !util.FileExists(certFile) { | ||||
| 		return errors.New("if set, certificate file must exist") | ||||
| 	} else if listenHTTPS != "" && (keyFile == "" || certFile == "") { | ||||
| 		return errors.New("if listen-https is set, both key-file and cert-file must be set") | ||||
| 	} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") { | ||||
| 		return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set") | ||||
| 	} else if smtpServerListen != "" && smtpServerDomain == "" { | ||||
| 		return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") | ||||
| 	} else if attachmentCacheDir != "" && baseURL == "" { | ||||
| 		return errors.New("if attachment-cache-dir is set, base-url must also be set") | ||||
| 	} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { | ||||
| 		return errors.New("if set, base-url must start with http:// or https://") | ||||
| 	} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { | ||||
| 		return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") | ||||
| 	} else if !util.InStringList([]string{"app", "home"}, webRoot) { | ||||
| 		return errors.New("if set, web-root must be 'home' or 'app'") | ||||
| 	} | ||||
| 
 | ||||
| 	// Default auth permissions | ||||
| 	webRootIsApp := webRoot == "app" | ||||
| 	authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" | ||||
| 	authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" | ||||
| 
 | ||||
| 	// Special case: Unset default | ||||
| 	if listenHTTP == "-" { | ||||
| 		listenHTTP = "" | ||||
| 	} | ||||
| 
 | ||||
| 	// Convert sizes to bytes | ||||
| 	attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt { | ||||
| 		return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt) | ||||
| 	} | ||||
| 
 | ||||
| 	// Resolve hosts | ||||
| 	visitorRequestLimitExemptIPs := make([]string, 0) | ||||
| 	for _, host := range visitorRequestLimitExemptHosts { | ||||
| 		ips, err := net.LookupIP(host) | ||||
| 		if err != nil { | ||||
| 			log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) | ||||
| 			continue | ||||
| 		} | ||||
| 		for _, ip := range ips { | ||||
| 			visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ip.String()) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Run server | ||||
| 	conf := server.NewConfig() | ||||
| 	conf.BaseURL = baseURL | ||||
| 	conf.ListenHTTP = listenHTTP | ||||
| 	conf.ListenHTTPS = listenHTTPS | ||||
| 	conf.ListenUnix = listenUnix | ||||
| 	conf.KeyFile = keyFile | ||||
| 	conf.CertFile = certFile | ||||
| 	conf.FirebaseKeyFile = firebaseKeyFile | ||||
| 	conf.CacheFile = cacheFile | ||||
| 	conf.CacheDuration = cacheDuration | ||||
| 	conf.AuthFile = authFile | ||||
| 	conf.AuthDefaultRead = authDefaultRead | ||||
| 	conf.AuthDefaultWrite = authDefaultWrite | ||||
| 	conf.AttachmentCacheDir = attachmentCacheDir | ||||
| 	conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit | ||||
| 	conf.AttachmentFileSizeLimit = attachmentFileSizeLimit | ||||
| 	conf.AttachmentExpiryDuration = attachmentExpiryDuration | ||||
| 	conf.KeepaliveInterval = keepaliveInterval | ||||
| 	conf.ManagerInterval = managerInterval | ||||
| 	conf.WebRootIsApp = webRootIsApp | ||||
| 	conf.SMTPSenderAddr = smtpSenderAddr | ||||
| 	conf.SMTPSenderUser = smtpSenderUser | ||||
| 	conf.SMTPSenderPass = smtpSenderPass | ||||
| 	conf.SMTPSenderFrom = smtpSenderFrom | ||||
| 	conf.SMTPServerListen = smtpServerListen | ||||
| 	conf.SMTPServerDomain = smtpServerDomain | ||||
| 	conf.SMTPServerAddrPrefix = smtpServerAddrPrefix | ||||
| 	conf.TotalTopicLimit = totalTopicLimit | ||||
| 	conf.VisitorSubscriptionLimit = visitorSubscriptionLimit | ||||
| 	conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit | ||||
| 	conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit) | ||||
| 	conf.VisitorRequestLimitBurst = visitorRequestLimitBurst | ||||
| 	conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish | ||||
| 	conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs | ||||
| 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst | ||||
| 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish | ||||
| 	conf.BehindProxy = behindProxy | ||||
| 	s, err := server.New(conf) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	if err := s.Run(); err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	log.Printf("Exiting.") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func parseSize(s string, defaultValue int64) (v int64, err error) { | ||||
| 	if s == "" { | ||||
| 		return defaultValue, nil | ||||
| 	} | ||||
| 	v, err = util.ParseSize(s) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	return v, nil | ||||
| } | ||||
							
								
								
									
										77
									
								
								cmd/serve_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,77 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/gorilla/websocket" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"heckel.io/ntfy/client" | ||||
| 	"heckel.io/ntfy/test" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"math/rand" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| func init() { | ||||
| 	rand.Seed(time.Now().UnixMilli()) | ||||
| } | ||||
| 
 | ||||
| func TestCLI_Serve_Unix_Curl(t *testing.T) { | ||||
| 	sockFile := filepath.Join(t.TempDir(), "ntfy.sock") | ||||
| 	configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system | ||||
| 	go func() { | ||||
| 		app, _, _, _ := newTestApp() | ||||
| 		err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile}) | ||||
| 		require.Nil(t, err) | ||||
| 	}() | ||||
| 	for i := 0; i < 40 && !util.FileExists(sockFile); i++ { | ||||
| 		time.Sleep(50 * time.Millisecond) | ||||
| 	} | ||||
| 	require.True(t, util.FileExists(sockFile)) | ||||
| 
 | ||||
| 	cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic") | ||||
| 	out, err := cmd.Output() | ||||
| 	require.Nil(t, err) | ||||
| 	m := toMessage(t, string(out)) | ||||
| 	require.Equal(t, "this is a message", m.Message) | ||||
| } | ||||
| 
 | ||||
| func TestCLI_Serve_WebSocket(t *testing.T) { | ||||
| 	port := 10000 + rand.Intn(20000) | ||||
| 	go func() { | ||||
| 		configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system | ||||
| 		app, _, _, _ := newTestApp() | ||||
| 		err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)}) | ||||
| 		require.Nil(t, err) | ||||
| 	}() | ||||
| 	test.WaitForPortUp(t, port) | ||||
| 
 | ||||
| 	ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil) | ||||
| 	require.Nil(t, err) | ||||
| 
 | ||||
| 	messageType, data, err := ws.ReadMessage() | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, websocket.TextMessage, messageType) | ||||
| 	require.Equal(t, "open", toMessage(t, string(data)).Event) | ||||
| 
 | ||||
| 	c := client.New(client.NewConfig()) | ||||
| 	_, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message") | ||||
| 	require.Nil(t, err) | ||||
| 
 | ||||
| 	messageType, data, err = ws.ReadMessage() | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, websocket.TextMessage, messageType) | ||||
| 
 | ||||
| 	m := toMessage(t, string(data)) | ||||
| 	require.Equal(t, "my message", m.Message) | ||||
| 	require.Equal(t, "mytopic", m.Topic) | ||||
| } | ||||
| 
 | ||||
| func newEmptyFile(t *testing.T) string { | ||||
| 	filename := filepath.Join(t.TempDir(), "empty") | ||||
| 	require.Nil(t, os.WriteFile(filename, []byte{}, 0600)) | ||||
| 	return filename | ||||
| } | ||||
							
								
								
									
										261
									
								
								cmd/subscribe.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,261 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"heckel.io/ntfy/client" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"os/user" | ||||
| 	"strings" | ||||
| ) | ||||
| 
 | ||||
| var cmdSubscribe = &cli.Command{ | ||||
| 	Name:      "subscribe", | ||||
| 	Aliases:   []string{"sub"}, | ||||
| 	Usage:     "Subscribe to one or more topics on a ntfy server", | ||||
| 	UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", | ||||
| 	Action:    execSubscribe, | ||||
| 	Category:  categoryClient, | ||||
| 	Flags: []cli.Flag{ | ||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, | ||||
| 		&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, | ||||
| 		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"}, | ||||
| 		&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"}, | ||||
| 		&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, | ||||
| 		&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, | ||||
| 		&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"}, | ||||
| 	}, | ||||
| 	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: | ||||
| 
 | ||||
| ntfy subscribe TOPIC | ||||
|   This prints the JSON representation of every incoming message. It is useful when you | ||||
|   have a command that wants to stream-read incoming JSON messages. Unless --poll is passed, | ||||
|   this command stays open forever.  | ||||
| 
 | ||||
|   Examples: | ||||
|     ntfy subscribe mytopic            # Prints JSON for incoming messages for ntfy.sh/mytopic | ||||
|     ntfy sub home.lan/backups         # Subscribe to topic on different server | ||||
|     ntfy sub --poll home.lan/backups  # Just query for latest messages and exit | ||||
|     ntfy sub -u phil:mypass secret    # Subscribe with username/password | ||||
|    | ||||
| ntfy subscribe TOPIC COMMAND | ||||
|   This executes COMMAND for every incoming messages. The message fields are passed to the | ||||
|   command as environment variables: | ||||
| 
 | ||||
|     Variable        Aliases               Description | ||||
|     --------------- --------------------- ----------------------------------- | ||||
|     $NTFY_ID        $id                   Unique message ID | ||||
|     $NTFY_TIME      $time                 Unix timestamp of the message delivery | ||||
|     $NTFY_TOPIC     $topic                Topic name | ||||
|     $NTFY_MESSAGE   $message, $m          Message body | ||||
|     $NTFY_TITLE     $title, $t            Message title | ||||
|     $NTFY_PRIORITY  $priority, $prio, $p  Message priority (1=min, 5=max) | ||||
|     $NTFY_TAGS      $tags, $tag, $ta      Message tags (comma separated list) | ||||
| 	$NTFY_RAW       $raw                  Raw JSON message | ||||
| 
 | ||||
|   Examples: | ||||
|     ntfy sub mytopic 'notify-send "$m"'    # Execute command for incoming messages | ||||
|     ntfy sub topic1 /my/script.sh          # Execute script for incoming messages | ||||
| 
 | ||||
| ntfy subscribe --from-config | ||||
|   Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml  | ||||
|   or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"  | ||||
|   block (see config file). | ||||
| 
 | ||||
|   Examples:  | ||||
|     ntfy sub --from-config                           # Read topics from config file | ||||
|     ntfy sub --config=/my/client.yml --from-config   # Read topics from alternate config file | ||||
| 
 | ||||
| The default config file for all client commands is /etc/ntfy/client.yml (if root user), | ||||
| or ~/.config/ntfy/client.yml for all other users.`, | ||||
| } | ||||
| 
 | ||||
| func execSubscribe(c *cli.Context) error { | ||||
| 	// Read config and options | ||||
| 	conf, err := loadConfig(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	cl := client.New(conf) | ||||
| 	since := c.String("since") | ||||
| 	user := c.String("user") | ||||
| 	poll := c.Bool("poll") | ||||
| 	scheduled := c.Bool("scheduled") | ||||
| 	fromConfig := c.Bool("from-config") | ||||
| 	topic := c.Args().Get(0) | ||||
| 	command := c.Args().Get(1) | ||||
| 	if !fromConfig { | ||||
| 		conf.Subscribe = nil // wipe if --from-config not passed | ||||
| 	} | ||||
| 	var options []client.SubscribeOption | ||||
| 	if since != "" { | ||||
| 		options = append(options, client.WithSince(since)) | ||||
| 	} | ||||
| 	if user != "" { | ||||
| 		var pass string | ||||
| 		parts := strings.SplitN(user, ":", 2) | ||||
| 		if len(parts) == 2 { | ||||
| 			user = parts[0] | ||||
| 			pass = parts[1] | ||||
| 		} else { | ||||
| 			fmt.Fprint(c.App.ErrWriter, "Enter Password: ") | ||||
| 			p, err := util.ReadPassword(c.App.Reader) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			pass = string(p) | ||||
| 			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) | ||||
| 		} | ||||
| 		options = append(options, client.WithBasicAuth(user, pass)) | ||||
| 	} | ||||
| 	if poll { | ||||
| 		options = append(options, client.WithPoll()) | ||||
| 	} | ||||
| 	if scheduled { | ||||
| 		options = append(options, client.WithScheduled()) | ||||
| 	} | ||||
| 	if topic == "" && len(conf.Subscribe) == 0 { | ||||
| 		return errors.New("must specify topic, type 'ntfy subscribe --help' for help") | ||||
| 	} | ||||
| 
 | ||||
| 	// Execute poll or subscribe | ||||
| 	if poll { | ||||
| 		return doPoll(c, cl, conf, topic, command, options...) | ||||
| 	} | ||||
| 	return doSubscribe(c, cl, conf, topic, command, options...) | ||||
| } | ||||
| 
 | ||||
| func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { | ||||
| 	for _, s := range conf.Subscribe { // may be nil | ||||
| 		if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	if topic != "" { | ||||
| 		if err := doPollSingle(c, cl, topic, command, options...); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error { | ||||
| 	messages, err := cl.Poll(topic, options...) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	for _, m := range messages { | ||||
| 		printMessageOrRunCommand(c, m, command) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { | ||||
| 	commands := make(map[string]string) // Subscription ID -> command | ||||
| 	for _, s := range conf.Subscribe {  // May be nil | ||||
| 		topicOptions := append(make([]client.SubscribeOption, 0), options...) | ||||
| 		for filter, value := range s.If { | ||||
| 			topicOptions = append(topicOptions, client.WithFilter(filter, value)) | ||||
| 		} | ||||
| 		if s.User != "" && s.Password != "" { | ||||
| 			topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password)) | ||||
| 		} | ||||
| 		subscriptionID := cl.Subscribe(s.Topic, topicOptions...) | ||||
| 		commands[subscriptionID] = s.Command | ||||
| 	} | ||||
| 	if topic != "" { | ||||
| 		subscriptionID := cl.Subscribe(topic, options...) | ||||
| 		commands[subscriptionID] = command | ||||
| 	} | ||||
| 	for m := range cl.Messages { | ||||
| 		command, ok := commands[m.SubscriptionID] | ||||
| 		if !ok { | ||||
| 			continue | ||||
| 		} | ||||
| 		printMessageOrRunCommand(c, m, command) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) { | ||||
| 	if command != "" { | ||||
| 		runCommand(c, command, m) | ||||
| 	} else { | ||||
| 		fmt.Fprintln(c.App.Writer, m.Raw) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func runCommand(c *cli.Context, command string, m *client.Message) { | ||||
| 	if err := runCommandInternal(c, command, m); err != nil { | ||||
| 		fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error()) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func runCommandInternal(c *cli.Context, command string, m *client.Message) error { | ||||
| 	scriptFile, err := createTmpScript(command) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer os.Remove(scriptFile) | ||||
| 	verbose := c.Bool("verbose") | ||||
| 	if verbose { | ||||
| 		log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw) | ||||
| 	} | ||||
| 	cmd := exec.Command("sh", "-c", scriptFile) | ||||
| 	cmd.Stdin = c.App.Reader | ||||
| 	cmd.Stdout = c.App.Writer | ||||
| 	cmd.Stderr = c.App.ErrWriter | ||||
| 	cmd.Env = envVars(m) | ||||
| 	return cmd.Run() | ||||
| } | ||||
| 
 | ||||
| func createTmpScript(command string) (string, error) { | ||||
| 	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10)) | ||||
| 	script := fmt.Sprintf("#!/bin/sh\n%s", command) | ||||
| 	if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	return scriptFile, nil | ||||
| } | ||||
| 
 | ||||
| func envVars(m *client.Message) []string { | ||||
| 	env := os.Environ() | ||||
| 	env = append(env, envVar(m.ID, "NTFY_ID", "id")...) | ||||
| 	env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) | ||||
| 	env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) | ||||
| 	env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...) | ||||
| 	env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...) | ||||
| 	env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) | ||||
| 	env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) | ||||
| 	env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) | ||||
| 	return env | ||||
| } | ||||
| 
 | ||||
| func envVar(value string, vars ...string) []string { | ||||
| 	env := make([]string, 0) | ||||
| 	for _, v := range vars { | ||||
| 		env = append(env, fmt.Sprintf("%s=%s", v, value)) | ||||
| 	} | ||||
| 	return env | ||||
| } | ||||
| 
 | ||||
| func loadConfig(c *cli.Context) (*client.Config, error) { | ||||
| 	filename := c.String("config") | ||||
| 	if filename != "" { | ||||
| 		return client.LoadConfig(filename) | ||||
| 	} | ||||
| 	u, _ := user.Current() | ||||
| 	configFile := defaultClientRootConfigFile | ||||
| 	if u.Uid != "0" { | ||||
| 		configFile = util.ExpandHome(defaultClientUserConfigFile) | ||||
| 	} | ||||
| 	if s, _ := os.Stat(configFile); s != nil { | ||||
| 		return client.LoadConfig(configFile) | ||||
| 	} | ||||
| 	return client.NewConfig(), nil | ||||
| } | ||||
							
								
								
									
										272
									
								
								cmd/user.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,272 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/subtle" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"github.com/urfave/cli/v2/altsrc" | ||||
| 	"heckel.io/ntfy/auth" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"strings" | ||||
| ) | ||||
| 
 | ||||
| var flagsUser = userCommandFlags() | ||||
| var cmdUser = &cli.Command{ | ||||
| 	Name:      "user", | ||||
| 	Usage:     "Manage/show users", | ||||
| 	UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", | ||||
| 	Flags:     flagsUser, | ||||
| 	Before:    initConfigFileInputSource("config", flagsUser), | ||||
| 	Category:  categoryServer, | ||||
| 	Subcommands: []*cli.Command{ | ||||
| 		{ | ||||
| 			Name:      "add", | ||||
| 			Aliases:   []string{"a"}, | ||||
| 			Usage:     "Adds a new user", | ||||
| 			UsageText: "ntfy user add [--role=admin|user] USERNAME", | ||||
| 			Action:    execUserAdd, | ||||
| 			Flags: []cli.Flag{ | ||||
| 				&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"}, | ||||
| 			}, | ||||
| 			Description: `Add a new user to the ntfy user database. | ||||
| 
 | ||||
| A user can be either a regular user, or an admin. A regular user has no read or write access (unless | ||||
| granted otherwise by the auth-default-access setting). An admin user has read and write access to all | ||||
| topics. | ||||
| 
 | ||||
| Examples: | ||||
|   ntfy user add phil                 # Add regular user phil   | ||||
|   ntfy user add --role=admin phil    # Add admin user phil | ||||
| `, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Name:      "remove", | ||||
| 			Aliases:   []string{"del", "rm"}, | ||||
| 			Usage:     "Removes a user", | ||||
| 			UsageText: "ntfy user remove USERNAME", | ||||
| 			Action:    execUserDel, | ||||
| 			Description: `Remove a user from the ntfy user database. | ||||
| 
 | ||||
| Example: | ||||
|   ntfy user del phil | ||||
| `, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Name:      "change-pass", | ||||
| 			Aliases:   []string{"chp"}, | ||||
| 			Usage:     "Changes a user's password", | ||||
| 			UsageText: "ntfy user change-pass USERNAME", | ||||
| 			Action:    execUserChangePass, | ||||
| 			Description: `Change the password for the given user. | ||||
| 
 | ||||
| The new password will be read from STDIN, and it'll be confirmed by typing | ||||
| it twice.  | ||||
| 
 | ||||
| Example: | ||||
|     ntfy user change-pass phil | ||||
| `, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Name:      "change-role", | ||||
| 			Aliases:   []string{"chr"}, | ||||
| 			Usage:     "Changes the role of a user", | ||||
| 			UsageText: "ntfy user change-role USERNAME ROLE", | ||||
| 			Action:    execUserChangeRole, | ||||
| 			Description: `Change the role for the given user to admin or user. | ||||
| 
 | ||||
| This command can be used to change the role of a user either from a regular user | ||||
| to an admin user, or the other way around: | ||||
| 
 | ||||
| - admin: an admin has read/write access to all topics | ||||
| - user: a regular user only has access to what was explicitly granted via 'ntfy access' | ||||
| 
 | ||||
| When changing the role of a user to "admin", all access control entries for that  | ||||
| user are removed, since they are no longer necessary. | ||||
| 
 | ||||
| Example: | ||||
|   ntfy user change-role phil admin   # Make user phil an admin  | ||||
|   ntfy user change-role phil user    # Remove admin role from user phil  | ||||
| `, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Name:    "list", | ||||
| 			Aliases: []string{"l"}, | ||||
| 			Usage:   "Shows a list of users", | ||||
| 			Action:  execUserList, | ||||
| 			Description: `Shows a list of all configured users, including the everyone ('*') user. | ||||
| 
 | ||||
| This is a server-only command. It directly reads from the user.db as defined in the server config | ||||
| file server.yml. The command only works if 'auth-file' is properly defined.  | ||||
| 
 | ||||
| This command is an alias to calling 'ntfy access' (display access control list). | ||||
| `, | ||||
| 		}, | ||||
| 	}, | ||||
| 	Description: `Manage users of the ntfy server. | ||||
| 
 | ||||
| This is a server-only command. It directly manages the user.db as defined in the server config | ||||
| file server.yml. The command only works if 'auth-file' is properly defined. Please also refer | ||||
| to the related command 'ntfy access'. | ||||
| 
 | ||||
| The command allows you to add/remove/change users in the ntfy user database, as well as change  | ||||
| passwords or roles. | ||||
| 
 | ||||
| Examples: | ||||
|   ntfy user list                     # Shows list of users (alias: 'ntfy access')                       | ||||
|   ntfy user add phil                 # Add regular user phil   | ||||
|   ntfy user add --role=admin phil    # Add admin user phil | ||||
|   ntfy user del phil                 # Delete user phil | ||||
|   ntfy user change-pass phil         # Change password for user phil | ||||
|   ntfy user change-role phil admin   # Make user phil an admin  | ||||
| `, | ||||
| } | ||||
| 
 | ||||
| func execUserAdd(c *cli.Context) error { | ||||
| 	username := c.Args().Get(0) | ||||
| 	role := auth.Role(c.String("role")) | ||||
| 	if username == "" { | ||||
| 		return errors.New("username expected, type 'ntfy user add --help' for help") | ||||
| 	} else if username == userEveryone { | ||||
| 		return errors.New("username not allowed") | ||||
| 	} else if !auth.AllowedRole(role) { | ||||
| 		return errors.New("role must be either 'user' or 'admin'") | ||||
| 	} | ||||
| 	manager, err := createAuthManager(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if user, _ := manager.User(username); user != nil { | ||||
| 		return fmt.Errorf("user %s already exists", username) | ||||
| 	} | ||||
| 	password, err := readPasswordAndConfirm(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := manager.AddUser(username, password, role); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func execUserDel(c *cli.Context) error { | ||||
| 	username := c.Args().Get(0) | ||||
| 	if username == "" { | ||||
| 		return errors.New("username expected, type 'ntfy user del --help' for help") | ||||
| 	} else if username == userEveryone { | ||||
| 		return errors.New("username not allowed") | ||||
| 	} | ||||
| 	manager, err := createAuthManager(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := manager.User(username); err == auth.ErrNotFound { | ||||
| 		return fmt.Errorf("user %s does not exist", username) | ||||
| 	} | ||||
| 	if err := manager.RemoveUser(username); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func execUserChangePass(c *cli.Context) error { | ||||
| 	username := c.Args().Get(0) | ||||
| 	if username == "" { | ||||
| 		return errors.New("username expected, type 'ntfy user change-pass --help' for help") | ||||
| 	} else if username == userEveryone { | ||||
| 		return errors.New("username not allowed") | ||||
| 	} | ||||
| 	manager, err := createAuthManager(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := manager.User(username); err == auth.ErrNotFound { | ||||
| 		return fmt.Errorf("user %s does not exist", username) | ||||
| 	} | ||||
| 	password, err := readPasswordAndConfirm(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := manager.ChangePassword(username, password); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func execUserChangeRole(c *cli.Context) error { | ||||
| 	username := c.Args().Get(0) | ||||
| 	role := auth.Role(c.Args().Get(1)) | ||||
| 	if username == "" || !auth.AllowedRole(role) { | ||||
| 		return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") | ||||
| 	} else if username == userEveryone { | ||||
| 		return errors.New("username not allowed") | ||||
| 	} | ||||
| 	manager, err := createAuthManager(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := manager.User(username); err == auth.ErrNotFound { | ||||
| 		return fmt.Errorf("user %s does not exist", username) | ||||
| 	} | ||||
| 	if err := manager.ChangeRole(username, role); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func execUserList(c *cli.Context) error { | ||||
| 	manager, err := createAuthManager(c) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	users, err := manager.Users() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return showUsers(c, manager, users) | ||||
| } | ||||
| 
 | ||||
| func createAuthManager(c *cli.Context) (auth.Manager, error) { | ||||
| 	authFile := c.String("auth-file") | ||||
| 	authDefaultAccess := c.String("auth-default-access") | ||||
| 	if authFile == "" { | ||||
| 		return nil, errors.New("option auth-file not set; auth is unconfigured for this server") | ||||
| 	} else if !util.FileExists(authFile) { | ||||
| 		return nil, errors.New("auth-file does not exist; please start the server at least once to create it") | ||||
| 	} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { | ||||
| 		return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'") | ||||
| 	} | ||||
| 	authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" | ||||
| 	authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" | ||||
| 	return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite) | ||||
| } | ||||
| 
 | ||||
| func readPasswordAndConfirm(c *cli.Context) (string, error) { | ||||
| 	fmt.Fprint(c.App.ErrWriter, "password: ") | ||||
| 	password, err := util.ReadPassword(c.App.Reader) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25)) | ||||
| 	confirm, err := util.ReadPassword(c.App.Reader) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25)) | ||||
| 	if subtle.ConstantTimeCompare(confirm, password) != 1 { | ||||
| 		return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly") | ||||
| 	} | ||||
| 	return string(password), nil | ||||
| } | ||||
| 
 | ||||
| func userCommandFlags() []cli.Flag { | ||||
| 	return []cli.Flag{ | ||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, | ||||
| 		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), | ||||
| 		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										145
									
								
								cmd/user_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,145 @@ | |||
| package cmd | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| 	"heckel.io/ntfy/server" | ||||
| 	"heckel.io/ntfy/test" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
| ) | ||||
| 
 | ||||
| func TestCLI_User_Add(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	app, stdin, _, stderr := newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil added with role user") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_User_Add_Exists(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	app, stdin, _, stderr := newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil added with role user") | ||||
| 
 | ||||
| 	app, stdin, _, _ = newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	err := runUserCommand(app, conf, "add", "phil") | ||||
| 	require.Error(t, err) | ||||
| 	require.Contains(t, err.Error(), "user phil already exists") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_User_Add_Admin(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	app, stdin, _, stderr := newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil added with role admin") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_User_Add_Password_Mismatch(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	app, stdin, _, _ := newTestApp() | ||||
| 	stdin.WriteString("mypass\nNOTMATCH") | ||||
| 	err := runUserCommand(app, conf, "add", "phil") | ||||
| 	require.Error(t, err) | ||||
| 	require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_User_ChangePass(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	// Add user | ||||
| 	app, stdin, _, stderr := newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil added with role user") | ||||
| 
 | ||||
| 	// Change pass | ||||
| 	app, stdin, _, stderr = newTestApp() | ||||
| 	stdin.WriteString("newpass\nnewpass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "change-pass", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "changed password for user phil") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_User_ChangeRole(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	// Add user | ||||
| 	app, stdin, _, stderr := newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil added with role user") | ||||
| 
 | ||||
| 	// Change role | ||||
| 	app, _, _, stderr = newTestApp() | ||||
| 	require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) | ||||
| 	require.Contains(t, stderr.String(), "changed role for user phil to admin") | ||||
| } | ||||
| 
 | ||||
| func TestCLI_User_Delete(t *testing.T) { | ||||
| 	s, conf, port := newTestServerWithAuth(t) | ||||
| 	defer test.StopServer(t, s, port) | ||||
| 
 | ||||
| 	// Add user | ||||
| 	app, stdin, _, stderr := newTestApp() | ||||
| 	stdin.WriteString("mypass\nmypass") | ||||
| 	require.Nil(t, runUserCommand(app, conf, "add", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil added with role user") | ||||
| 
 | ||||
| 	// Delete user | ||||
| 	app, _, _, stderr = newTestApp() | ||||
| 	require.Nil(t, runUserCommand(app, conf, "del", "phil")) | ||||
| 	require.Contains(t, stderr.String(), "user phil removed") | ||||
| 
 | ||||
| 	// Delete user again (does not exist) | ||||
| 	app, _, _, _ = newTestApp() | ||||
| 	err := runUserCommand(app, conf, "del", "phil") | ||||
| 	require.Error(t, err) | ||||
| 	require.Contains(t, err.Error(), "user phil does not exist") | ||||
| } | ||||
| 
 | ||||
| func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { | ||||
| 	conf = server.NewConfig() | ||||
| 	conf.AuthFile = filepath.Join(t.TempDir(), "user.db") | ||||
| 	conf.AuthDefaultRead = false | ||||
| 	conf.AuthDefaultWrite = false | ||||
| 	s, port = test.StartServerWithConfig(t, conf) | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| func runUserCommand(app *cli.App, conf *server.Config, args ...string) error { | ||||
| 	userArgs := []string{ | ||||
| 		"ntfy", | ||||
| 		"user", | ||||
| 		"--auth-file=" + conf.AuthFile, | ||||
| 		"--auth-default-access=" + confToDefaultAccess(conf), | ||||
| 	} | ||||
| 	return app.Run(append(userArgs, args...)) | ||||
| } | ||||
| 
 | ||||
| func confToDefaultAccess(conf *server.Config) string { | ||||
| 	var defaultAccess string | ||||
| 	if conf.AuthDefaultRead && conf.AuthDefaultWrite { | ||||
| 		defaultAccess = "read-write" | ||||
| 	} else if conf.AuthDefaultRead && !conf.AuthDefaultWrite { | ||||
| 		defaultAccess = "read-only" | ||||
| 	} else if !conf.AuthDefaultRead && conf.AuthDefaultWrite { | ||||
| 		defaultAccess = "write-only" | ||||
| 	} else if !conf.AuthDefaultRead && !conf.AuthDefaultWrite { | ||||
| 		defaultAccess = "deny-all" | ||||
| 	} | ||||
| 	return defaultAccess | ||||
| } | ||||
|  | @ -1,63 +0,0 @@ | |||
| // Package config provides the main configuration | ||||
| package config | ||||
| 
 | ||||
| import ( | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| // Defines default config settings | ||||
| const ( | ||||
| 	DefaultListenHTTP        = ":80" | ||||
| 	DefaultCacheDuration     = 12 * time.Hour | ||||
| 	DefaultKeepaliveInterval = 30 * time.Second | ||||
| 	DefaultManagerInterval   = time.Minute | ||||
| ) | ||||
| 
 | ||||
| // Defines all the limits | ||||
| // - global topic limit: max number of topics overall | ||||
| // - per visistor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds) | ||||
| // - per visistor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP | ||||
| const ( | ||||
| 	DefaultGlobalTopicLimit             = 5000 | ||||
| 	DefaultVisitorRequestLimitBurst     = 60 | ||||
| 	DefaultVisitorRequestLimitReplenish = 10 * time.Second | ||||
| 	DefaultVisitorSubscriptionLimit     = 30 | ||||
| ) | ||||
| 
 | ||||
| // Config is the main config struct for the application. Use New to instantiate a default config struct. | ||||
| type Config struct { | ||||
| 	ListenHTTP                   string | ||||
| 	ListenHTTPS                   string | ||||
| 	KeyFile                   string | ||||
| 	CertFile                   string | ||||
| 	FirebaseKeyFile              string | ||||
| 	CacheFile                    string | ||||
| 	CacheDuration                time.Duration | ||||
| 	KeepaliveInterval            time.Duration | ||||
| 	ManagerInterval              time.Duration | ||||
| 	GlobalTopicLimit             int | ||||
| 	VisitorRequestLimitBurst     int | ||||
| 	VisitorRequestLimitReplenish time.Duration | ||||
| 	VisitorSubscriptionLimit     int | ||||
| 	BehindProxy                  bool | ||||
| } | ||||
| 
 | ||||
| // New instantiates a default new config | ||||
| func New(listenHTTP string) *Config { | ||||
| 	return &Config{ | ||||
| 		ListenHTTP:                   listenHTTP, | ||||
| 		ListenHTTPS: "", | ||||
| 		KeyFile: "", | ||||
| 		CertFile: "", | ||||
| 		FirebaseKeyFile:              "", | ||||
| 		CacheFile:                    "", | ||||
| 		CacheDuration:                DefaultCacheDuration, | ||||
| 		KeepaliveInterval:            DefaultKeepaliveInterval, | ||||
| 		ManagerInterval:              DefaultManagerInterval, | ||||
| 		GlobalTopicLimit:             DefaultGlobalTopicLimit, | ||||
| 		VisitorRequestLimitBurst:     DefaultVisitorRequestLimitBurst, | ||||
| 		VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, | ||||
| 		VisitorSubscriptionLimit:     DefaultVisitorSubscriptionLimit, | ||||
| 		BehindProxy:                  false, | ||||
| 	} | ||||
| } | ||||
|  | @ -1,71 +0,0 @@ | |||
| # ntfy config file | ||||
| 
 | ||||
| # Listen address for the HTTP web server | ||||
| # Format: <hostname>:<port> | ||||
| # | ||||
| # listen-http: ":80" | ||||
| 
 | ||||
| # Listen address for the HTTPS web server. If set, you must also set "key-file" and "cert-file". | ||||
| # Format: <hostname>:<port> | ||||
| # | ||||
| # listen-https: | ||||
| 
 | ||||
| # Path to the private key file for the HTTPS web server. Not used if "listen-https" is not set. | ||||
| # Format: <filename> | ||||
| # | ||||
| # key-file: | ||||
| 
 | ||||
| # Path to the cert file for the HTTPS web server. Not used if "listen-https" is not set. | ||||
| # Format: <filename> | ||||
| # | ||||
| # cert-file: | ||||
| 
 | ||||
| # 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. | ||||
| # | ||||
| # firebase-key-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. | ||||
| # | ||||
| # cache-file: <filename> | ||||
| 
 | ||||
| # Duration for which messages will be buffered before they are deleted. | ||||
| # This is required to support the "since=..." and "poll=1" parameter. | ||||
| # | ||||
| # cache-duration: 12h | ||||
| 
 | ||||
| # 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. | ||||
| # | ||||
| # keepalive-interval: 30s | ||||
| 
 | ||||
| # Interval in which the manager prunes old messages, deletes topics | ||||
| # and prints the stats. | ||||
| # | ||||
| # manager-interval: 1m | ||||
| 
 | ||||
| # Rate limiting: Total number of topics before the server rejects new topics. | ||||
| # | ||||
| # global-topic-limit: 5000 | ||||
| 
 | ||||
| # Rate limiting: Number of subscriptions per visitor (IP address) | ||||
| # | ||||
| # visitor-subscription-limit: 30 | ||||
| 
 | ||||
| # Rate limiting: Allowed GET/PUT/POST requests per second, per visitor: | ||||
| # - visitor-request-limit-burst is the initial bucket of requests each visitor has | ||||
| # - visitor-request-limit-replenish is the rate at which the bucket is refilled | ||||
| # | ||||
| # visitor-request-limit-burst: 60 | ||||
| # visitor-request-limit-replenish: 10s | ||||
| 
 | ||||
| # If set, the X-Forwarded-For header is used to determine the visitor IP address | ||||
| # instead of the remote address of the connection. | ||||
| # | ||||
| # WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited | ||||
| #          as if they are one. | ||||
| # | ||||
| # behind-proxy: false | ||||
							
								
								
									
										774
									
								
								docs/config.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,21 +1,70 @@ | |||
| # Configuring the ntfy server | ||||
| The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/config.yml`,  | ||||
| see [config.yml](https://github.com/binwiederhier/ntfy/blob/main/config/config.yml)), via command line arguments  | ||||
| The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,  | ||||
| see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments  | ||||
| or using environment variables. | ||||
| 
 | ||||
| ## Quick start | ||||
| By default, simply running `ntfy` will start the server at port 80. No configuration needed. Batteries included 😀.  | ||||
| By default, simply running `ntfy serve` will start the server at port 80. No configuration needed. Batteries included 😀.  | ||||
| If everything works as it should, you'll see something like this: | ||||
| ``` | ||||
| $ ntfy                 | ||||
| $ ntfy serve | ||||
| 2021/11/30 19:59:08 Listening on :80 | ||||
| ``` | ||||
| 
 | ||||
| You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md), | ||||
| [the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure  | ||||
| the server further, check out the [config options table](#config-options) or simply type `ntfy --help` to | ||||
| the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to | ||||
| get a list of [command line options](#command-line-options). | ||||
| 
 | ||||
| ## Example config | ||||
| !!! info | ||||
|     Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. | ||||
|     It contains examples and detailed descriptions of all the settings. | ||||
| 
 | ||||
| The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http` | ||||
| and `listen-https`), and socket path (`listen-unix`). All the other things are additional features. | ||||
| 
 | ||||
| Here are a few working sample configs: | ||||
| 
 | ||||
| === "server.yml (HTTP-only, with cache + attachments)" | ||||
|     ``` yaml | ||||
|     base-url: "http://ntfy.example.com" | ||||
|     cache-file: "/var/cache/ntfy/cache.db" | ||||
|     attachment-cache-dir: "/var/cache/ntfy/attachments" | ||||
|     ``` | ||||
| 
 | ||||
| === "server.yml (HTTP+HTTPS, with cache + attachments)" | ||||
|     ``` yaml | ||||
|     base-url: "http://ntfy.example.com" | ||||
|     listen-http: ":80" | ||||
|     listen-https: ":443" | ||||
|     key-file: "/etc/letsencrypt/live/ntfy.example.com.key" | ||||
|     cert-file: "/etc/letsencrypt/live/ntfy.example.com.crt" | ||||
|     cache-file: "/var/cache/ntfy/cache.db" | ||||
|     attachment-cache-dir: "/var/cache/ntfy/attachments" | ||||
|     ``` | ||||
| 
 | ||||
| === "server.yml (ntfy.sh config)" | ||||
|     ``` yaml | ||||
|     # All the things: Behind a proxy, Firebase, cache, attachments,  | ||||
|     # SMTP publishing & receiving | ||||
| 
 | ||||
|     base-url: "https://ntfy.sh" | ||||
|     listen-http: "127.0.0.1:2586" | ||||
|     firebase-key-file: "/etc/ntfy/firebase.json" | ||||
|     cache-file: "/var/cache/ntfy/cache.db" | ||||
|     behind-proxy: true | ||||
|     attachment-cache-dir: "/var/cache/ntfy/attachments" | ||||
|     smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" | ||||
|     smtp-sender-user: "AKIDEADBEEFAFFE12345" | ||||
|     smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." | ||||
|     smtp-sender-from: "ntfy@ntfy.sh" | ||||
|     smtp-server-listen: ":25" | ||||
|     smtp-server-domain: "ntfy.sh" | ||||
|     smtp-server-addr-prefix: "ntfy-" | ||||
|     keepalive-interval: "45s" | ||||
|     ``` | ||||
| 
 | ||||
| ## Message cache | ||||
| If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period | ||||
| of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve | ||||
|  | @ -26,24 +75,465 @@ restart**. You can override this behavior using the following config settings: | |||
| 
 | ||||
| * `cache-file`: if set, ntfy will store messages in a SQLite based cache (default is empty, which means in-memory cache). | ||||
|   **This is required if you'd like messages to be retained across restarts**. | ||||
| * `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`) | ||||
| * `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`).  | ||||
| 
 | ||||
| Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#polling), as well as the | ||||
| [`since=` parameter](subscribe/api.md#fetching-cached-messages). | ||||
| You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only | ||||
| passed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward | ||||
| the message to the subscribers. | ||||
| 
 | ||||
| Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the | ||||
| [`since=` parameter](subscribe/api.md#fetch-cached-messages). | ||||
| 
 | ||||
| ## Attachments | ||||
| If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable | ||||
| this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`).  | ||||
| Once these options are set and the directory is writable by the server user, you can upload attachments via PUT. | ||||
| 
 | ||||
| By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues | ||||
| and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download  | ||||
| feature) to download the file. The following config options are relevant to attachments: | ||||
| 
 | ||||
| * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs | ||||
| * `attachment-cache-dir` is the cache directory for attached files | ||||
| * `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G) | ||||
| * `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) | ||||
| * `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) | ||||
| 
 | ||||
| Here's an example config using mostly the defaults (except for the cache directory, which is empty by default):  | ||||
| 
 | ||||
| === "/etc/ntfy/server.yml (minimal)" | ||||
|     ``` yaml | ||||
|     base-url: "https://ntfy.sh" | ||||
|     attachment-cache-dir: "/var/cache/ntfy/attachments" | ||||
|     ``` | ||||
| 
 | ||||
| === "/etc/ntfy/server.yml (all options)" | ||||
|     ``` yaml | ||||
|     base-url: "https://ntfy.sh" | ||||
|     attachment-cache-dir: "/var/cache/ntfy/attachments" | ||||
|     attachment-total-size-limit: "5G" | ||||
|     attachment-file-size-limit: "15M" | ||||
|     attachment-expiry-duration: "3h" | ||||
|     visitor-attachment-total-size-limit: "100M" | ||||
|     visitor-attachment-daily-bandwidth-limit: "500M" | ||||
|     ``` | ||||
| 
 | ||||
| Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` | ||||
| and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. | ||||
| 
 | ||||
| ## Access control | ||||
| By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how | ||||
| ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization.  | ||||
| 
 | ||||
| ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles  | ||||
| (`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).  | ||||
| Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.  | ||||
| 
 | ||||
| To set up auth, simply **configure the following two options**: | ||||
| 
 | ||||
| * `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested  | ||||
|   location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) | ||||
| * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be | ||||
|   set to `read-write` (default), `read-only`, `write-only` or `deny-all`. | ||||
| 
 | ||||
| Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command | ||||
| lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these  | ||||
| commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user  | ||||
| accessing them has the right permissions. | ||||
| 
 | ||||
| ### Users and roles | ||||
| The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change | ||||
| passwords or roles (`user` or `admin`). In practice, you'll often just create one admin  | ||||
| user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)). | ||||
| 
 | ||||
| **Roles:** | ||||
| 
 | ||||
| * Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access` | ||||
|   (see [below](#access-control-list-acl)). | ||||
| * Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary. | ||||
| 
 | ||||
| **Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details): | ||||
| 
 | ||||
| ``` | ||||
| ntfy user list                     # Shows list of users (alias: 'ntfy access') | ||||
| ntfy user add phil                 # Add regular user phil   | ||||
| ntfy user add --role=admin phil    # Add admin user phil | ||||
| ntfy user del phil                 # Delete user phil | ||||
| ntfy user change-pass phil         # Change password for user phil | ||||
| ntfy user change-role phil admin   # Make user phil an admin | ||||
| ``` | ||||
| 
 | ||||
| ### Access control list (ACL) | ||||
| The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**. | ||||
| Each entry represents the access permissions for a user to a specific topic or topic pattern.  | ||||
| 
 | ||||
| The ACL can be displayed or modified with the `ntfy access` command: | ||||
| 
 | ||||
| ``` | ||||
| ntfy access                            # Shows access control list (alias: 'ntfy user list') | ||||
| ntfy access USERNAME                   # Shows access control entries for USERNAME | ||||
| ntfy access USERNAME TOPIC PERMISSION  # Allow/deny access for USERNAME to TOPIC | ||||
| ``` | ||||
| 
 | ||||
| A `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the  | ||||
| anonymous user `everyone` or `*`, which represents clients that access the API without username/password. | ||||
| 
 | ||||
| A `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any | ||||
| number of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any  | ||||
| number of characters. | ||||
| 
 | ||||
| A `PERMISSION` is any of the following supported permissions: | ||||
| 
 | ||||
| * `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as  | ||||
|   [subscribing](subscribe/api.md) and reading messages | ||||
| * `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic | ||||
| * `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it | ||||
| * `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic  | ||||
| 
 | ||||
| **Example commands** (type `ntfy access --help` for more details): | ||||
| ``` | ||||
| ntfy access                        # Shows entire access control list | ||||
| ntfy access phil                   # Shows access for user phil | ||||
| ntfy access phil mytopic rw        # Allow read-write access to mytopic for user phil | ||||
| ntfy access everyone mytopic rw    # Allow anonymous read-write access to mytopic | ||||
| ntfy access everyone "up*" write   # Allow anonymous write-only access to topics "up..." | ||||
| ntfy access --reset                # Reset entire access control list | ||||
| ntfy access --reset phil           # Reset all access for user phil | ||||
| ntfy access --reset phil mytopic   # Reset access for user phil and topic mytopic | ||||
| ``` | ||||
| 
 | ||||
| **Example ACL:** | ||||
| ``` | ||||
| $ ntfy access | ||||
| user phil (admin) | ||||
| - read-write access to all topics (admin role) | ||||
| user ben (user) | ||||
| - read-write access to topic garagedoor | ||||
| - read-write access to topic alerts* | ||||
| - read-only access to topic furnace | ||||
| user * (anonymous) | ||||
| - read-only access to topic announcements | ||||
| - read-only access to topic server-stats | ||||
| - no access to any (other) topics (server config) | ||||
| ``` | ||||
| 
 | ||||
| In this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary). | ||||
| User `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access | ||||
| to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated | ||||
| (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. | ||||
| 
 | ||||
| ### Example: Private instance | ||||
| The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: | ||||
| 
 | ||||
| === "/etc/ntfy/server.yml" | ||||
|     ``` yaml | ||||
|     auth-file "/var/lib/ntfy/user.db" | ||||
|     auth-default-access: "deny-all" | ||||
|     ``` | ||||
| 
 | ||||
| After that, simply create an `admin` user: | ||||
| 
 | ||||
| ``` | ||||
| $ ntfy user add --role=admin phil | ||||
| password: mypass | ||||
| confirm: mypass | ||||
| user phil added with role admin  | ||||
| ``` | ||||
| 
 | ||||
| Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)  | ||||
| with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example: | ||||
| 
 | ||||
| === "Command line (curl)" | ||||
|     ``` | ||||
|     curl \ | ||||
|         -u phil:mypass \ | ||||
|         -d "Look ma, with auth" \ | ||||
|         https://ntfy.example.com/mysecrets | ||||
|     ``` | ||||
| 
 | ||||
| === "ntfy CLI" | ||||
|     ``` | ||||
|     ntfy publish \ | ||||
|         -u phil:mypass \ | ||||
|         ntfy.example.com/mysecrets \ | ||||
|         "Look ma, with auth" | ||||
|     ``` | ||||
| 
 | ||||
| === "HTTP" | ||||
|     ``` http | ||||
|     POST /mysecrets HTTP/1.1 | ||||
|     Host: ntfy.example.com | ||||
|     Authorization: Basic cGhpbDpteXBhc3M= | ||||
| 
 | ||||
|     Look ma, with auth | ||||
|     ``` | ||||
| 
 | ||||
| === "JavaScript" | ||||
|     ``` javascript | ||||
|     fetch('https://ntfy.example.com/mysecrets', { | ||||
|         method: 'POST', // PUT works too | ||||
|         body: 'Look ma, with auth', | ||||
|         headers: { | ||||
|             'Authorization': 'Basic cGhpbDpteXBhc3M=' | ||||
|         } | ||||
|     }) | ||||
|     ``` | ||||
| 
 | ||||
| === "Go" | ||||
|     ``` go | ||||
|     req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", | ||||
|         strings.NewReader("Look ma, with auth")) | ||||
|     req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=") | ||||
|     http.DefaultClient.Do(req) | ||||
|     ``` | ||||
| 
 | ||||
| === "Python" | ||||
|     ``` python | ||||
|     requests.post("https://ntfy.example.com/mysecrets", | ||||
|         data="Look ma, with auth", | ||||
|         headers={ | ||||
|             "Authorization": "Basic cGhpbDpteXBhc3M=" | ||||
|         }) | ||||
|     ``` | ||||
| 
 | ||||
| === "PHP" | ||||
|     ``` php-inline | ||||
|     file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ | ||||
|         'http' => [ | ||||
|             'method' => 'POST', // PUT also works | ||||
|             'header' =>  | ||||
|                 'Content-Type: text/plain\r\n' . | ||||
|                 'Authorization: Basic cGhpbDpteXBhc3M=', | ||||
|             'content' => 'Look ma, with auth' | ||||
|         ] | ||||
|     ])); | ||||
|     ``` | ||||
| 
 | ||||
| ## E-mail notifications | ||||
| To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,  | ||||
| you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.  | ||||
| `curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`). | ||||
| 
 | ||||
| As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the  | ||||
| following settings: | ||||
| 
 | ||||
| * `base-url` is the root URL for the ntfy server; this is needed for e-mail footer | ||||
| * `smtp-sender-addr` is the hostname:port of the SMTP server | ||||
| * `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user | ||||
| * `smtp-sender-from` is the e-mail address of the sender | ||||
| 
 | ||||
| Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is  | ||||
| configured for `ntfy.sh`): | ||||
| 
 | ||||
| === "/etc/ntfy/server.yml" | ||||
|     ``` yaml | ||||
|     base-url: "https://ntfy.sh" | ||||
|     smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" | ||||
|     smtp-sender-user: "AKIDEADBEEFAFFE12345" | ||||
|     smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." | ||||
|     smtp-sender-from: "ntfy@ntfy.sh" | ||||
|     ``` | ||||
| 
 | ||||
| Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`  | ||||
| and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. | ||||
| 
 | ||||
| ## E-mail publishing | ||||
| To allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured,  | ||||
| users can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or  | ||||
| `myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for  | ||||
| statuspage.io (though these days most services also support webhooks and HTTP calls). | ||||
| 
 | ||||
| To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`: | ||||
| 
 | ||||
| * `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | ||||
| * `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` | ||||
| * `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance, | ||||
|   only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be | ||||
|   accepted (which may obviously be a spam problem). | ||||
| 
 | ||||
| Here's an example config (this is how it is configured for `ntfy.sh`): | ||||
| 
 | ||||
| === "/etc/ntfy/server.yml" | ||||
|     ``` yaml | ||||
|     smtp-server-listen: ":25" | ||||
|     smtp-server-domain: "ntfy.sh" | ||||
|     smtp-server-addr-prefix: "ntfy-" | ||||
|     ``` | ||||
| 
 | ||||
| In addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record)  | ||||
| and a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is  | ||||
| configured (in [Amazon Route 53](https://aws.amazon.com/route53/)): | ||||
| 
 | ||||
| <figure markdown> | ||||
|   { width=600 } | ||||
|   <figcaption>DNS records for incoming mail</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| ## Behind a proxy (TLS, etc.) | ||||
| 
 | ||||
| !!! warning | ||||
|     If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise all visitors are rate limited | ||||
|     as if they are one. | ||||
|     If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are | ||||
|     [rate limited](#rate-limiting) as if they are one. | ||||
| 
 | ||||
| **Rate limiting:** If you are running ntfy behind a proxy (e.g. nginx, HAproxy or Apache), you should set the `behind-proxy`  | ||||
| flag. This will instruct the [rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary  | ||||
| identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will | ||||
| It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates  | ||||
| using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.  | ||||
| Whatever your reasons may be, there are a few things to consider.  | ||||
| 
 | ||||
| If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the  | ||||
| [rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,  | ||||
| as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will | ||||
| be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address. | ||||
| 
 | ||||
| **TLS/SSL:** ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you  | ||||
| are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself. | ||||
| === "/etc/ntfy/server.yml" | ||||
|     ``` yaml | ||||
|     # Tell ntfy to use "X-Forwarded-For" to identify visitors | ||||
|     behind-proxy: true | ||||
|     ``` | ||||
| 
 | ||||
| ### TLS/SSL | ||||
| ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you  | ||||
| are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below). | ||||
| 
 | ||||
| I highly recommend using [certbot](https://certbot.eff.org/). I use it with the [dns-route53 plugin](https://certbot-dns-route53.readthedocs.io/en/stable/),  | ||||
| which lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challenge. That's much easier than using the | ||||
| HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to | ||||
| be incredibly helpful. | ||||
| 
 | ||||
| ### nginx/Apache2/caddy | ||||
| For your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets** | ||||
| by forwarding the `Connection` and `Upgrade` headers accordingly.  | ||||
| 
 | ||||
| In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic | ||||
| or the root domain: | ||||
| 
 | ||||
| === "nginx (/etc/nginx/sites-*/ntfy)" | ||||
|     ``` | ||||
|     server { | ||||
|       listen 80; | ||||
|       server_name ntfy.sh; | ||||
| 
 | ||||
|       location / { | ||||
|         # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want  | ||||
|         # it to work with curl without the annoying https:// prefix | ||||
|         set $redirect_https ""; | ||||
|         if ($request_method = GET) { | ||||
|           set $redirect_https "yes"; | ||||
|         } | ||||
|         if ($request_uri ~* "^/([-_a-z0-9]{0,64}$|docs/|static/)") { | ||||
|           set $redirect_https "${redirect_https}yes"; | ||||
|         } | ||||
|         if ($redirect_https = "yesyes") { | ||||
|           return 302 https://$http_host$request_uri$is_args$query_string; | ||||
|         } | ||||
| 
 | ||||
|         proxy_pass http://127.0.0.1:2586; | ||||
|         proxy_http_version 1.1; | ||||
|      | ||||
|         proxy_buffering off; | ||||
|         proxy_request_buffering off; | ||||
|         proxy_redirect off; | ||||
|       | ||||
|         proxy_set_header Host $http_host; | ||||
|         proxy_set_header Upgrade $http_upgrade; | ||||
|         proxy_set_header Connection "upgrade"; | ||||
|         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||
|      | ||||
|         proxy_connect_timeout 3m; | ||||
|         proxy_send_timeout 3m; | ||||
|         proxy_read_timeout 3m; | ||||
| 
 | ||||
|         client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     server { | ||||
|       listen 443 ssl; | ||||
|       server_name ntfy.sh; | ||||
|      | ||||
|       ssl_session_cache builtin:1000 shared:SSL:10m; | ||||
|       ssl_protocols TLSv1 TLSv1.1 TLSv1.2; | ||||
|       ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4; | ||||
|       ssl_prefer_server_ciphers on; | ||||
|      | ||||
|       ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem; | ||||
|       ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem; | ||||
|      | ||||
|       location / { | ||||
|         proxy_pass http://127.0.0.1:2586; | ||||
|         proxy_http_version 1.1; | ||||
| 
 | ||||
|         proxy_buffering off; | ||||
|         proxy_request_buffering off; | ||||
|         proxy_redirect off; | ||||
|       | ||||
|         proxy_set_header Host $http_host; | ||||
|         proxy_set_header Upgrade $http_upgrade; | ||||
|         proxy_set_header Connection "upgrade"; | ||||
|         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||
|      | ||||
|         proxy_connect_timeout 3m; | ||||
|         proxy_send_timeout 3m; | ||||
|         proxy_read_timeout 3m; | ||||
|          | ||||
|         client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml | ||||
|       } | ||||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| === "Apache2 (/etc/apache2/sites-*/ntfy.conf)" | ||||
|     ``` | ||||
|     <VirtualHost *:80> | ||||
|         ServerName ntfy.sh | ||||
|          | ||||
|         SetEnv proxy-nokeepalive 1 | ||||
|         SetEnv proxy-sendchunked 1 | ||||
|          | ||||
|         ProxyPass / http://127.0.0.1:2586/ | ||||
|         ProxyPassReverse / http://127.0.0.1:2586/ | ||||
|          | ||||
|         # Higher than the max message size of 4096 bytes | ||||
|         LimitRequestBody 102400 | ||||
|          | ||||
|         # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want  | ||||
|         # it to work with curl without the annoying https:// prefix  | ||||
|         RewriteEngine on | ||||
|         RewriteCond %{REQUEST_METHOD} GET | ||||
|         RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L] | ||||
|     </VirtualHost> | ||||
|      | ||||
|     <VirtualHost *:443> | ||||
|         ServerName ntfy.sh | ||||
|          | ||||
|         SSLEngine on | ||||
|         SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem | ||||
|         SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem | ||||
|         Include /etc/letsencrypt/options-ssl-apache.conf | ||||
|          | ||||
|         SetEnv proxy-nokeepalive 1 | ||||
|         SetEnv proxy-sendchunked 1 | ||||
|          | ||||
|         ProxyPass / http://127.0.0.1:2586/ | ||||
|         ProxyPassReverse / http://127.0.0.1:2586/ | ||||
|          | ||||
|         # Higher than the max message size of 4096 bytes  | ||||
|         LimitRequestBody 102400 | ||||
|          | ||||
|         # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want  | ||||
|         # it to work with curl without the annoying https:// prefix  | ||||
|         RewriteEngine on | ||||
|         RewriteCond %{REQUEST_METHOD} GET | ||||
|         RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L] | ||||
|     </VirtualHost> | ||||
|     ``` | ||||
| 
 | ||||
| === "caddy" | ||||
|     ``` | ||||
|     # Note that this config is most certainly incomplete. Please help out and let me know what's missing | ||||
|     # via Discord/Matrix or in a GitHub issue. | ||||
| 
 | ||||
|     ntfy.sh, http://nfty.sh { | ||||
|         reverse_proxy 127.0.0.1:2586 | ||||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| ## Firebase (FCM) | ||||
| !!! info | ||||
|  | @ -61,7 +551,7 @@ To configure FCM for your self-hosted instance of the ntfy server, follow these | |||
| 
 | ||||
| 1. Sign up for a [Firebase account](https://console.firebase.google.com/) | ||||
| 2. Create a Firebase app and download the key file (e.g. `myapp-firebase-adminsdk-...json`) | ||||
| 3. Place the key file in `/etc/ntfy`, set the `firebase-key-file` in `config.yml` accordingly and restart the ntfy server | ||||
| 3. Place the key file in `/etc/ntfy`, set the `firebase-key-file` in `server.yml` accordingly and restart the ntfy server | ||||
| 4. Build your own Android .apk following [these instructions](develop.md#android-app) | ||||
| 
 | ||||
| Example: | ||||
|  | @ -75,80 +565,260 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" | |||
| ## Rate limiting | ||||
| !!! info | ||||
|     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.  | ||||
|     Otherwise all visitors are rate limited as if they are one. | ||||
|     Otherwise, all visitors are rate limited as if they are one. | ||||
| 
 | ||||
| By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload. | ||||
| There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first: | ||||
| There are various limits and rate limits in place that you can use to configure the server: | ||||
| 
 | ||||
| * `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 5000. | ||||
| * **Global limit**: A global limit applies across all visitors (IPs, clients, users) | ||||
| * **Visitor limit**: A visitor limit only applies to a certain visitor. A **visitor** is identified by its IP address  | ||||
|   (or the `X-Forwarded-For` header if `behind-proxy` is set). All config options that start with the word `visitor` apply  | ||||
|   only on a per-visitor basis. | ||||
| 
 | ||||
| During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails | ||||
| (e.g. when you reconnect after a connection drop), it shouldn't have any effect. | ||||
| 
 | ||||
| ### General limits | ||||
| Let's do the easy limits first: | ||||
| 
 | ||||
| * `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000. | ||||
| * `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30. | ||||
| 
 | ||||
| A **visitor** is identified by its IP address (or the `X-Forwarded-For` header if `behind-proxy` is set). All config  | ||||
| options that start with the word `visitor` apply only on a per-visitor basis.    | ||||
| 
 | ||||
| ### Request limits | ||||
| In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests. | ||||
| This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)): | ||||
| 
 | ||||
| Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`).  | ||||
| After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one | ||||
| request every 10s (defined by `visitor-request-limit-replenish`) | ||||
| request every 5s (defined by `visitor-request-limit-replenish`) | ||||
| 
 | ||||
| * `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60. | ||||
| * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s. | ||||
| * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s. | ||||
| * `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate  | ||||
|   limiting; hostnames are resolved at the time the server is started. Defaults to an empty list. | ||||
|   | ||||
| During normal usage, you shouldn't encounter this limit at all, and even if you burst a few requests shortly (e.g. when you  | ||||
| reconnect after a connection drop), it shouldn't have any effect. | ||||
| ### Attachment limits | ||||
| Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant  | ||||
| per-visitor limits: | ||||
| 
 | ||||
| * `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M. | ||||
|   The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`,  | ||||
|   see [publishing docs](publish.md#attachments)) do not count here.  | ||||
| * `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor,  | ||||
|   including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in | ||||
|   most cloud providers. This defaults to 500M. | ||||
| 
 | ||||
| ### E-mail limits | ||||
| Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications)  | ||||
| are enabled): | ||||
| 
 | ||||
| * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. | ||||
| * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. | ||||
| 
 | ||||
| ## Tuning for scale | ||||
| If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, | ||||
| if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. | ||||
| This limit is typically called `nofile`. Other than that, RAM and CPU are obviously relevant. You may also want to check | ||||
| out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/how_many_actively_connected_http_clients_can_a_go/). | ||||
| 
 | ||||
| Depending on *how you run it*, here are a few limits that are relevant: | ||||
| 
 | ||||
| ### For systemd services | ||||
| If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the | ||||
| `LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it | ||||
| by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf` | ||||
| is not relevant. | ||||
| 
 | ||||
| === "/etc/systemd/system/ntfy.service.d/override.conf" | ||||
|     ``` | ||||
|     # Allow 20,000 ntfy connections (and give room for other file handles) | ||||
|     [Service] | ||||
|     LimitNOFILE=20500 | ||||
|     ``` | ||||
| 
 | ||||
| ### Outside of systemd | ||||
| If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to | ||||
| increase the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting | ||||
| by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`. | ||||
| 
 | ||||
| === "/etc/security/limits.conf" | ||||
|     ``` | ||||
|     # Increase open files limit globally | ||||
|     * hard nofile 20500 | ||||
|     ``` | ||||
| 
 | ||||
| ### Proxy limits (nginx, Apache2) | ||||
| If you are running [behind a proxy](#behind-a-proxy-tls-etc) (e.g. nginx, Apache), the open files limit of the proxy is also | ||||
| relevant. So if your proxy runs inside of systemd, increase the limits in systemd for the proxy. Typically, the proxy | ||||
| open files limit has to be **double the number of how many connections you'd like to support**, because the proxy has | ||||
| to maintain the client connection and the connection to ntfy. | ||||
| 
 | ||||
| === "/etc/nginx/nginx.conf" | ||||
|     ``` | ||||
|     events { | ||||
|       # Allow 40,000 proxy connections (2x of the desired ntfy connection count; | ||||
|       # and give room for other file handles) | ||||
|       worker_connections 40500; | ||||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| === "/etc/systemd/system/nginx.service.d/override.conf" | ||||
|     ``` | ||||
|     # Allow 40,000 proxy connections (2x of the desired ntfy connection count; | ||||
|     # and give room for other file handles) | ||||
|     [Service] | ||||
|     LimitNOFILE=40500 | ||||
|     ``` | ||||
| 
 | ||||
| ### Banning bad actors (fail2ban) | ||||
| If you put stuff on the Internet, bad actors will try to break them or break in. [fail2ban](https://www.fail2ban.org/) | ||||
| and nginx's [ngx_http_limit_req_module module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html) can be used | ||||
| to ban client IPs if they misbehave. This is on top of the [rate limiting](#rate-limiting) inside the ntfy server. | ||||
| 
 | ||||
| Here's an example for how ntfy.sh is configured, following the instructions from two tutorials ([here](https://easyengine.io/tutorials/nginx/fail2ban/)  | ||||
| and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-attack/)): | ||||
| 
 | ||||
| === "/etc/nginx/nginx.conf" | ||||
|     ``` | ||||
|     http { | ||||
| 	  limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; | ||||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| === "/etc/nginx/sites-enabled/ntfy.sh" | ||||
|     ``` | ||||
|     # For each server/location block | ||||
|     server { | ||||
|       location / { | ||||
|         limit_req zone=one burst=1000 nodelay; | ||||
|       } | ||||
|     }     | ||||
|     ``` | ||||
| 
 | ||||
| === "/etc/fail2ban/filter.d/nginx-req-limit.conf" | ||||
|     ``` | ||||
|     [Definition] | ||||
|     failregex = limiting requests, excess:.* by zone.*client: <HOST> | ||||
|     ignoreregex = | ||||
|     ``` | ||||
| 
 | ||||
| === "/etc/fail2ban/jail.local" | ||||
|     ``` | ||||
|     [nginx-req-limit] | ||||
|     enabled = true | ||||
|     filter = nginx-req-limit | ||||
|     action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp] | ||||
|     logpath = /var/log/nginx/error.log | ||||
|     findtime = 600 | ||||
|     bantime = 7200 | ||||
|     maxretry = 10 | ||||
|     ``` | ||||
| 
 | ||||
| ## Config options | ||||
| Each config option can be set in the config file `/etc/ntfy/config.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 | ||||
| variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | ||||
| 
 | ||||
| | Config option                              | Env variable                                    | Format                                              | Default      | Description                                                                                                                                                                                                                     | | ||||
| |---|---|---|---|---| | ||||
| |--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ||||
| | `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -            | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  | | ||||
| | `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`        | Listen address for the HTTP web server                                                                                                                                                                                          | | ||||
| | `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -            | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               | | ||||
| | `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -            | Path to a Unix socket to listen on                                                                                                                                                                                              | | ||||
| | `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -            | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||
| | `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -            | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||
| | `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -            | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm).                        | | ||||
| | `cache-file`                               | `NTFY_CACHE_FILE`                               | *filename*                                          | -            | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache).             | | ||||
| | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. | | ||||
| | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 30s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | ||||
| | `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | | ||||
| | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. | | ||||
| | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | ||||
| | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | 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* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | | ||||
| | `cache-duration`                           | `NTFY_CACHE_DURATION`                           | *duration*                                          | 12h          | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely.                                        | | ||||
| | `auth-file`                                | `NTFY_AUTH_FILE`                                | *filename*                                          | -            | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control).                                                                                           | | ||||
| | `auth-default-access`                      | `NTFY_AUTH_DEFAULT_ACCESS`                      | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`.                                                                                                                             | | ||||
| | `behind-proxy`                             | `NTFY_BEHIND_PROXY`                             | *bool*                                              | false        | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection.                                                                                                 | | ||||
| | `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -            | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  | | ||||
| | `attachment-total-size-limit`              | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT`              | *size*                                              | 5G           | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected.                                                                                                                   | | ||||
| | `attachment-file-size-limit`               | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT`               | *size*                                              | 15M          | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected.                                                                                                                                       | | ||||
| | `attachment-expiry-duration`               | `NTFY_ATTACHMENT_EXPIRY_DURATION`               | *duration*                                          | 3h           | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`.                                                                                               | | ||||
| | `smtp-sender-addr`                         | `NTFY_SMTP_SENDER_ADDR`                         | `host:port`                                         | -            | SMTP server address to allow email sending                                                                                                                                                                                      | | ||||
| | `smtp-sender-user`                         | `NTFY_SMTP_SENDER_USER`                         | *string*                                            | -            | SMTP user; only used if e-mail sending is enabled                                                                                                                                                                               | | ||||
| | `smtp-sender-pass`                         | `NTFY_SMTP_SENDER_PASS`                         | *string*                                            | -            | SMTP password; only used if e-mail sending is enabled                                                                                                                                                                           | | ||||
| | `smtp-sender-from`                         | `NTFY_SMTP_SENDER_FROM`                         | *e-mail address*                                    | -            | SMTP sender e-mail address; only used if e-mail sending is enabled                                                                                                                                                              | | ||||
| | `smtp-server-listen`                       | `NTFY_SMTP_SERVER_LISTEN`                       | `[ip]:port`                                         | -            | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`                                                                                                                                      | | ||||
| | `smtp-server-domain`                       | `NTFY_SMTP_SERVER_DOMAIN`                       | *domain name*                                       | -            | SMTP server e-mail domain, e.g. `ntfy.sh`                                                                                                                                                                                       | | ||||
| | `smtp-server-addr-prefix`                  | `NTFY_SMTP_SERVER_ADDR_PREFIX`                  | `[ip]:port`                                         | -            | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-`                                                                                                                                                          | | ||||
| | `keepalive-interval`                       | `NTFY_KEEPALIVE_INTERVAL`                       | *duration*                                          | 45s          | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | ||||
| | `manager-interval`                         | `$NTFY_MANAGER_INTERVAL`                        | *duration*                                          | 1m           | Interval in which the manager prunes old messages, deletes topics and prints the stats.                                                                                                                                         | | ||||
| | `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app` or `home`                                     | `app`        | Sets web root to landing page (home) or web app (app)                                                                                                                                                                           | | ||||
| | `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000       | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     | | ||||
| | `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30           | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 | | ||||
| | `visitor-attachment-total-size-limit`      | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT`      | *size*                                              | 100M         | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`.                                                 | | ||||
| | `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size*                                              | 500M         | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding.                                                                                        | | ||||
| | `visitor-request-limit-burst`              | `NTFY_VISITOR_REQUEST_LIMIT_BURST`              | *number*                                            | 60           | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has                                                                                           | | ||||
| | `visitor-request-limit-replenish`          | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH`          | *duration*                                          | 5s           | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      | | ||||
| | `visitor-request-limit-exempt-hosts`       | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS`       | *comma-separated host/IP list*                      | -            | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting                                                                                                                                                | | ||||
| | `visitor-email-limit-burst`                | `NTFY_VISITOR_EMAIL_LIMIT_BURST`                | *number*                                            | 16           | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              | | ||||
| | `visitor-email-limit-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*                                          | 1h           | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        | | ||||
| 
 | ||||
| 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. | ||||
| 
 | ||||
| ## Command line options | ||||
| ``` | ||||
| $ ntfy --help | ||||
| $ ntfy serve --help | ||||
| NAME: | ||||
|    ntfy - Simple pub-sub notification service | ||||
|    ntfy serve - Run the ntfy server | ||||
| 
 | ||||
| USAGE: | ||||
|    ntfy [OPTION..] | ||||
|    ntfy serve [OPTIONS..] | ||||
| 
 | ||||
| GLOBAL OPTIONS: | ||||
|    --config value, -c value                           config file (default: /etc/ntfy/config.yml) [$NTFY_CONFIG_FILE] | ||||
|    --listen-http value, -l value                      ip:port used to as listen address (default: ":80") [$NTFY_LISTEN_HTTP] | ||||
| CATEGORY: | ||||
|    Server commands | ||||
| 
 | ||||
| DESCRIPTION: | ||||
|    Run the ntfy server and listen for incoming requests | ||||
|     | ||||
|    The command will load the configuration from /etc/ntfy/server.yml. Config options can  | ||||
|    be overridden using the command line options. | ||||
|     | ||||
|    Examples: | ||||
|      ntfy serve                      # Starts server in the foreground (on port 80) | ||||
|      ntfy serve --listen-http :8080  # Starts server with alternate port | ||||
| 
 | ||||
| OPTIONS: | ||||
|    --config value, -c value                          config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] | ||||
|    --base-url value, -B value                        externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] | ||||
|    --listen-http value, -l value                     ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] | ||||
|    --listen-https value, -L value                    ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] | ||||
|    --listen-unix value, -U value                     listen on unix socket path [$NTFY_LISTEN_UNIX] | ||||
|    --key-file value, -K value                        private key file, if listen-https is set [$NTFY_KEY_FILE] | ||||
|    --cert-file value, -E value                       certificate file, if listen-https is set [$NTFY_CERT_FILE] | ||||
|    --firebase-key-file value, -F value               Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] | ||||
|    --cache-file value, -C value                      cache file used for message caching [$NTFY_CACHE_FILE] | ||||
|    --cache-duration since, -b since                  buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] | ||||
|    --keepalive-interval value, -k value               interval of keepalive messages (default: 30s) [$NTFY_KEEPALIVE_INTERVAL] | ||||
|    --auth-file value, -H value                       auth database file used for access control [$NTFY_AUTH_FILE] | ||||
|    --auth-default-access value, -p value             default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] | ||||
|    --attachment-cache-dir value                      cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] | ||||
|    --attachment-total-size-limit value, -A value     limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] | ||||
|    --attachment-file-size-limit value, -Y value      per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] | ||||
|    --attachment-expiry-duration value, -X value      duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] | ||||
|    --keepalive-interval value, -k value              interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] | ||||
|    --manager-interval value, -m value                interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] | ||||
|    --global-topic-limit value, -T value               total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT] | ||||
|    --visitor-subscription-limit value, -V value       number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] | ||||
|    --visitor-request-limit-burst value, -B value      initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] | ||||
|    --visitor-request-limit-replenish value, -R value  interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] | ||||
|    --web-root value                                  sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT] | ||||
|    --smtp-sender-addr value                          SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] | ||||
|    --smtp-sender-user value                          SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] | ||||
|    --smtp-sender-pass value                          SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] | ||||
|    --smtp-sender-from value                          SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] | ||||
|    --smtp-server-listen value                        SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] | ||||
|    --smtp-server-domain value                        SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] | ||||
|    --smtp-server-addr-prefix value                   SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] | ||||
|    --global-topic-limit value, -T value              total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] | ||||
|    --visitor-subscription-limit value                number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] | ||||
|    --visitor-attachment-total-size-limit value       total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] | ||||
|    --visitor-attachment-daily-bandwidth-limit value  total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] | ||||
|    --visitor-request-limit-burst value               initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] | ||||
|    --visitor-request-limit-replenish value           interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] | ||||
|    --visitor-request-limit-exempt-hosts value        hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] | ||||
|    --visitor-email-limit-burst value                 initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] | ||||
|    --visitor-email-limit-replenish value             interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] | ||||
|    --behind-proxy, -P                                if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] | ||||
| 
 | ||||
| Try 'ntfy COMMAND --help' for more information. | ||||
| 
 | ||||
| ntfy v1.4.8 (7b8185c), runtime go1.17, built at 1637872539 | ||||
| Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0 | ||||
|    --help, -h                                        show help (default: false) | ||||
| ``` | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										41
									
								
								docs/deprecations.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,41 @@ | |||
| # Deprecation notices | ||||
| This page is used to list deprecation notices for ntfy. Deprecated commands and options will be  | ||||
| **removed after ~3 months** from the time they were deprecated. | ||||
| 
 | ||||
| ## Active deprecations | ||||
| 
 | ||||
| ### Android app: WebSockets will become the default connection protocol   | ||||
| > Active since 2022-03-13, behavior will change in **June 2022** | ||||
| 
 | ||||
| In future versions of the Android app, instant delivery connections and connections to self-hosted servers will | ||||
| be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy). | ||||
| 
 | ||||
| ### Android app: Using `since=<timestamp>` instead of `since=<id>` | ||||
| > Active since 2022-02-27, behavior will change in **May 2022** | ||||
| 
 | ||||
| In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will | ||||
| not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app. | ||||
| 
 | ||||
| The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change. | ||||
| 
 | ||||
| ## Previous deprecations | ||||
| 
 | ||||
| ### Running server via `ntfy` (instead of `ntfy serve`) | ||||
| > Deprecated 2021-12-17, behavior changed with v1.10.0 | ||||
| 
 | ||||
| As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical | ||||
| anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than | ||||
| just the server. | ||||
| 
 | ||||
| === "Before" | ||||
|     ``` | ||||
|     $ ntfy | ||||
|     2021/12/17 08:16:01 Listening on :80/http | ||||
|     ``` | ||||
| 
 | ||||
| === "After" | ||||
|     ``` | ||||
|     $ ntfy serve | ||||
|     2021/12/17 08:16:01 Listening on :80/http | ||||
|     ``` | ||||
| 
 | ||||
|  | @ -16,6 +16,27 @@ rsync -a root@laptop /backups/laptop \ | |||
|   || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups | ||||
| ``` | ||||
| 
 | ||||
| ## Low disk space alerts | ||||
| Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but  | ||||
| effective.  | ||||
| 
 | ||||
| ``` bash  | ||||
| #!/bin/bash | ||||
| 
 | ||||
| mingigs=10 | ||||
| avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }') | ||||
| topicurl=https://ntfy.sh/mytopic | ||||
| 
 | ||||
| if [ -n "$avail" ]; then | ||||
|   curl \ | ||||
|     -d "Only $avail GB available on the root disk. Better clean that up." \ | ||||
|     -H "Title: Low disk space alert on $(hostname)" \ | ||||
|     -H "Priority: high" \ | ||||
|     -H "Tags: warning,cd" \ | ||||
|     $topicurl | ||||
| fi | ||||
| ``` | ||||
| 
 | ||||
| ## Server-sent messages in your web app | ||||
| Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own | ||||
| web application. Check out the <a href="/example.html">live example</a> or just look the source of this page. | ||||
|  | @ -64,5 +85,48 @@ It looked something like this: | |||
|     curl -d "$(hostname),$count,$time" ntfy.sh/results | ||||
|     ``` | ||||
| 
 | ||||
| ## Ansible, Salt and Puppet | ||||
| You can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated. | ||||
| One of my co-workers uses the following Ansible task to let him know when things are done: | ||||
| 
 | ||||
| ```yml | ||||
| - name: Send ntfy.sh update | ||||
|   uri: | ||||
|     url: "https://ntfy.sh/{{ ntfy_channel }}" | ||||
|     method: POST | ||||
|     body: "{{ inventory_hostname }} reseeding complete" | ||||
| ``` | ||||
| 
 | ||||
| ## Watchtower notifications (shoutrrr) | ||||
| You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic. | ||||
| 
 | ||||
| Example docker-compose.yml: | ||||
| ```yml | ||||
| services: | ||||
|   watchtower: | ||||
|     image: containrrr/watchtower | ||||
|     environment: | ||||
|       - WATCHTOWER_NOTIFICATIONS=shoutrrr | ||||
|       - WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates | ||||
| ``` | ||||
| 
 | ||||
| Or, if you only want to send notifications using shoutrrr: | ||||
| ``` | ||||
| shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" | ||||
| ``` | ||||
| 
 | ||||
| ## Random cronjobs | ||||
| Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with | ||||
| GitHub have been hopeless. In case it ever becomes available, I want to know immediately. | ||||
| 
 | ||||
| ``` cron | ||||
| # Check github/ntfy user | ||||
| */6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi | ||||
| ~            | ||||
| ``` | ||||
| 
 | ||||
| ## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd) | ||||
| 
 | ||||
| It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc. | ||||
| 
 | ||||
| Some simple bash scripts to achieve this are available <a href="https://github.com/nickexyz/ntfy-shellscripts">here</a> | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ subscribed to a topic. | |||
| ## Will you know what topics exist, can you spy on me? | ||||
| If you don't trust me or your messages are sensitive, run your own server. It's <a href="https://github.com/binwiederhier/ntfy">open source</a>. | ||||
| That said, the logs do not contain any topic names or other details about you. | ||||
| Messages are cached for the duration configured in `config.yml` (12h by default) to facilitate service restarts, message polling and to overcome | ||||
| Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome | ||||
| client network disruptions. | ||||
| 
 | ||||
| ## Can I self-host it? | ||||
|  | @ -33,10 +33,11 @@ If you do not care for Firebase, I suggest you install the [F-Droid version](htt | |||
| of the app and [self-host your own ntfy server](install.md). | ||||
| 
 | ||||
| ## How much battery does the Android app use? | ||||
| If you use the ntfy.sh server and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,  | ||||
| If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,  | ||||
| the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,  | ||||
| or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 4% of | ||||
| battery in 17h of use (on my phone). I use it, and it makes no difference to me. | ||||
| or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of | ||||
| battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty  | ||||
| decent now. | ||||
| 
 | ||||
| ## What is instant delivery? | ||||
| [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the | ||||
|  |  | |||
|  | @ -22,14 +22,20 @@ For this guide, we'll just use `mytopic` as our topic name: | |||
| That's it. After you tap "Subscribe", the app is listening for new messages on that topic. | ||||
| 
 | ||||
| ## Step 2: Send a message | ||||
| Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT or POST. The message | ||||
| is in the request body. Here's an example showing how to publish a simple message using a POST request: | ||||
| Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT/POST, | ||||
| or with the [ntfy CLI](install.md). The message is in the request body. Here's an example showing how to publish a  | ||||
| simple message using a POST request: | ||||
| 
 | ||||
| === "Command line (curl)" | ||||
|     ``` | ||||
|     curl -d "Backup successful 😀" ntfy.sh/mytopic | ||||
|     ``` | ||||
| 
 | ||||
| === "ntfy CLI" | ||||
|     ``` | ||||
|     ntfy publish mytopic "Backup successful 😀" | ||||
|     ``` | ||||
| 
 | ||||
| === "HTTP" | ||||
|     ``` http | ||||
|     POST /mytopic HTTP/1.1 | ||||
|  | @ -52,6 +58,12 @@ is in the request body. Here's an example showing how to publish a simple messag | |||
|        strings.NewReader("Backup successful 😀")) | ||||
|     ``` | ||||
| 
 | ||||
| === "Python" | ||||
|     ``` python | ||||
|     requests.post("https://ntfy.sh/mytopic", | ||||
|         data="Backup successful 😀".encode(encoding='utf-8')) | ||||
|     ``` | ||||
| 
 | ||||
| === "PHP" | ||||
|     ``` php-inline | ||||
|     file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ | ||||
|  | @ -66,7 +78,7 @@ is in the request body. Here's an example showing how to publish a simple messag | |||
| This will create a notification that looks like this: | ||||
| 
 | ||||
| <figure markdown> | ||||
|   { width=500 } | ||||
|   { width=500 } | ||||
|   <figcaption>Android notification</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
|  | @ -76,7 +88,7 @@ That's it. You're all set. Go play and read the rest of the docs. I highly recom | |||
| Here's another video showing the entire process: | ||||
| 
 | ||||
| <figure> | ||||
|   <video controls muted autoplay loop width="650" src="static/img/overview.mp4"></video> | ||||
|   <video controls muted autoplay loop width="650" src="static/img/android-video-overview.mp4"></video> | ||||
|   <figcaption>Sending push notifications to your Android phone</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,18 +1,24 @@ | |||
| # Install your own ntfy server | ||||
| **Self-hosting your own ntfy server** is pretty straight forward. Just install the binary, package or Docker image, then  | ||||
| # Installing ntfy | ||||
| The `ntfy` CLI allows you to [publish messages](publish.md), [subscribe to topics](subscribe/cli.md) as well as to | ||||
| self-host your own ntfy server. It's all pretty straight forward. Just install the binary, package or Docker image,  | ||||
| configure it and run it. Just like any other software. No fuzz.  | ||||
| 
 | ||||
| !!! info | ||||
|     The following steps are only required if you want to **self-host your own ntfy server**. If you just want to  | ||||
|     [send messages using ntfy.sh](publish.md), you don't need to install anything. | ||||
|     The following steps are only required if you want to **self-host your own ntfy server or you want to use the ntfy CLI**. | ||||
|     If you just want to [send messages using ntfy.sh](publish.md), you don't need to install anything. You can just use | ||||
|     `curl`. | ||||
| 
 | ||||
| ## General steps | ||||
| The ntfy server comes as a statically linked binary and is shipped as tarball, deb/rpm packages and as a Docker image. | ||||
| We support amd64, armv7 and arm64. | ||||
| 
 | ||||
| 1. Install ntfy using one of the methods described below | ||||
| 2. Then (optionally) edit `/etc/ntfy/config.yml` (see [configuration](config.md)) | ||||
| 3. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm). | ||||
| 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)) | ||||
| 
 | ||||
| To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm). | ||||
| To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md] | ||||
| for details).  | ||||
| 
 | ||||
| ## Binaries and packages | ||||
| Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and | ||||
|  | @ -20,23 +26,29 @@ deb/rpm packages. | |||
| 
 | ||||
| === "x86_64/amd64" | ||||
|     ```bash | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_x86_64.tar.gz | ||||
|     sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy | ||||
|     sudo ./ntfy | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_x86_64.tar.gz | ||||
|     tar zxvf ntfy_1.18.0_linux_x86_64.tar.gz | ||||
|     sudo cp -a ntfy_1.18.0_linux_x86_64/ntfy /usr/bin/ntfy | ||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.0_linux_x86_64/{client,server}/*.yml /etc/ntfy | ||||
|     sudo ntfy serve | ||||
|     ``` | ||||
| 
 | ||||
| === "armv7/armhf" | ||||
|     ```bash | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.tar.gz | ||||
|     sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy | ||||
|     sudo ./ntfy | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.tar.gz | ||||
|     tar zxvf ntfy_1.18.0_linux_armv7.tar.gz | ||||
|     sudo cp -a ntfy_1.18.0_linux_armv7/ntfy /usr/bin/ntfy | ||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.0_linux_armv7/{client,server}/*.yml /etc/ntfy | ||||
|     sudo ntfy serve | ||||
|     ``` | ||||
| 
 | ||||
| === "arm64" | ||||
|     ```bash | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.tar.gz | ||||
|     sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy | ||||
|     sudo ./ntfy | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.tar.gz | ||||
|     tar zxvf ntfy_1.18.0_linux_arm64.tar.gz | ||||
|     sudo cp -a ntfy_1.18.0_linux_arm64/ntfy /usr/bin/ntfy | ||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.0_linux_arm64/{client,server}/*.yml /etc/ntfy | ||||
|     sudo ntfy serve | ||||
|     ``` | ||||
| 
 | ||||
| ## Debian/Ubuntu repository | ||||
|  | @ -82,7 +94,7 @@ Manually installing the .deb file: | |||
| 
 | ||||
| === "x86_64/amd64" | ||||
|     ```bash | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_amd64.deb | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_amd64.deb | ||||
|     sudo dpkg -i ntfy_*.deb | ||||
|     sudo systemctl enable ntfy | ||||
|     sudo systemctl start ntfy | ||||
|  | @ -90,7 +102,7 @@ Manually installing the .deb file: | |||
| 
 | ||||
| === "armv7/armhf" | ||||
|     ```bash | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.deb | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.deb | ||||
|     sudo dpkg -i ntfy_*.deb | ||||
|     sudo systemctl enable ntfy | ||||
|     sudo systemctl start ntfy | ||||
|  | @ -98,7 +110,7 @@ Manually installing the .deb file: | |||
| 
 | ||||
| === "arm64" | ||||
|     ```bash | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.deb | ||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.deb | ||||
|     sudo dpkg -i ntfy_*.deb | ||||
|     sudo systemctl enable ntfy | ||||
|     sudo systemctl start ntfy | ||||
|  | @ -108,36 +120,50 @@ Manually installing the .deb file: | |||
| 
 | ||||
| === "x86_64/amd64" | ||||
|     ```bash | ||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_amd64.rpm | ||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_amd64.rpm | ||||
|     sudo systemctl enable ntfy  | ||||
|     sudo systemctl start ntfy | ||||
|     ``` | ||||
| 
 | ||||
| === "armv7/armhf" | ||||
|     ```bash | ||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.rpm | ||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.rpm | ||||
|     sudo systemctl enable ntfy  | ||||
|     sudo systemctl start ntfy | ||||
|     ``` | ||||
| 
 | ||||
| === "arm64" | ||||
|     ```bash | ||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.rpm | ||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.rpm | ||||
|     sudo systemctl enable ntfy  | ||||
|     sudo systemctl start ntfy | ||||
|     ``` | ||||
| 
 | ||||
| ## Arch Linux | ||||
| ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date. | ||||
| ``` | ||||
| paru -S ntfysh-bin | ||||
| ``` | ||||
| 
 | ||||
| Alternatively, run the following commands to install ntfy manually: | ||||
| ``` | ||||
| curl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv | ||||
| cd ntfysh-bin | ||||
| makepkg -si | ||||
| ``` | ||||
| 
 | ||||
| 
 | ||||
| ## Docker | ||||
| The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty | ||||
| straight forward to use. | ||||
| 
 | ||||
| The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent  | ||||
| [message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings, you should map `/etc/ntfy`, | ||||
| so you can edit `/etc/ntfy/config.yml`. | ||||
| [message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,  | ||||
| you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`. | ||||
| 
 | ||||
| Basic usage (no cache or additional config): | ||||
| ``` | ||||
| docker run -p 80:80 -it binwiederhier/ntfy | ||||
| docker run -p 80:80 -it binwiederhier/ntfy serve | ||||
| ``` | ||||
| 
 | ||||
| With persistent cache (configured as command line arguments): | ||||
|  | @ -147,18 +173,28 @@ docker run \ | |||
|   -p 80:80 \ | ||||
|   -it \ | ||||
|   binwiederhier/ntfy \ | ||||
|     --cache-file /var/cache/ntfy/cache.db | ||||
|     --cache-file /var/cache/ntfy/cache.db \ | ||||
|     serve | ||||
| ``` | ||||
| 
 | ||||
| With other config options (configured via `/etc/ntfy/config.yml`, see [configuration](config.md) for details): | ||||
| With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details): | ||||
| ```bash | ||||
| docker run \ | ||||
|   -v /etc/ntfy:/etc/ntfy \ | ||||
|   -p 80:80 \ | ||||
|   -it \ | ||||
|   binwiederhier/ntfy | ||||
|   binwiederhier/ntfy \ | ||||
|   serve | ||||
| ``` | ||||
| 
 | ||||
| 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 | ||||
| COPY server.yml /etc/ntfy/server.yml | ||||
| ENTRYPOINT ["ntfy", "serve"] | ||||
| ``` | ||||
| This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port. | ||||
| 
 | ||||
| ## Go | ||||
| To install via Go, simply run: | ||||
| ```bash | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ Here's an example showing how to publish a simple message using a POST request: | |||
| 
 | ||||
| === "PowerShell" | ||||
|     ``` powershell | ||||
|     Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup Successful 😀" -UseBasicParsing | ||||
|     Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing | ||||
|     ``` | ||||
| 
 | ||||
| === "Python" | ||||
|  | @ -126,7 +126,7 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an | |||
|     ``` powershell | ||||
|     $uri = "https://ntfy.sh/phil_alerts" | ||||
|     $headers = @{ Title="Unauthorized access detected" | ||||
|                   Priority="Urgent" | ||||
|                   Priority="urgent" | ||||
|                   Tags="warning,skull" } | ||||
|     $body = "Remote access to phils-laptop detected. Act right away."               | ||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing | ||||
|  | @ -246,7 +246,7 @@ notification sounds and vibration patterns on your phone to map to these priorit | |||
| The following priorities exist: | ||||
| 
 | ||||
| | Priority             | Icon                                       | ID  | Name           | Description                                                                                            | | ||||
| |---|---|---|---|---| | ||||
| |----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------| | ||||
| | Max priority         |  | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification.                 | | ||||
| | High priority        |  | `4` | `high`         | Long vibration burst, default notification sound with a pop-over notification.                         | | ||||
| | **Default priority** | *(none)*                                   | `3` | `default`      | Short default vibration and sound. Default notification behavior.                                      | | ||||
|  | @ -297,7 +297,7 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P | |||
| === "PowerShell" | ||||
|     ``` powershell | ||||
|     $uri = "https://ntfy.sh/phil_alerts" | ||||
|     $headers = @{ Priority="Urgent" } | ||||
|     $headers = @{ Priority="5" } | ||||
|     $body = "An urgent message" | ||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing | ||||
|     ``` | ||||
|  | @ -1117,7 +1117,7 @@ that, your IP address appears in the e-mail body. This is to prevent abuse. | |||
|     ``` powershell | ||||
|     $uri = "https://ntfy.sh/alerts" | ||||
|     $headers = @{ Title"="Low disk space alert" | ||||
|                   Priority=4 | ||||
|                   Priority="high" | ||||
|                   Tags="warning,skull,backup-host,ssh-login") | ||||
|                   Email="phil@example.com" } | ||||
|     $body = "Unknown login from 5.31.23.83 to backups.example.com" | ||||
|  | @ -1237,8 +1237,7 @@ Here's a simple example: | |||
| === "PowerShell" | ||||
|     ``` powershell | ||||
|     $uri = "https://ntfy.example.com/mysecrets" | ||||
|     $basicAuthValue = "Basic [user:pass-bese64encoded]" | ||||
|     $headers = @{ Authorization=$basicAuthValue } | ||||
|     $headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" } | ||||
|     $body = "Look ma, with auth" | ||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing | ||||
|     ``` | ||||
|  |  | |||
							
								
								
									
										262
									
								
								docs/releases.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,262 @@ | |||
| # Release notes | ||||
| Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) | ||||
| and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). | ||||
| 
 | ||||
| <!-- | ||||
| ## ntfy Android app v1.10.0 (UNRELEASED) | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130)) | ||||
| * Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting) | ||||
| 
 | ||||
| ## ntfy server v1.19.0 (UNRELEASED) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Fix install instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting) | ||||
| 
 | ||||
| --> | ||||
| 
 | ||||
| ## ntfy server v1.18.0 | ||||
| Released Mar 16, 2022 | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133),  | ||||
|   thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and  | ||||
|   [@Fallenbagel](https://github.com/Fallenbagel) for testing) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting) | ||||
| * Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert)) | ||||
| * Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares)) | ||||
| 
 | ||||
| **Deprecations:** | ||||
| 
 | ||||
| * Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md) | ||||
| 
 | ||||
| ## ntfy server v1.17.1 | ||||
| Released Mar 12, 2022 | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Replace `crypto.subtle` with `hashCode` to errors with Brave/FF-Windows (#157, thanks for reporting @arminus) | ||||
| 
 | ||||
| ## ntfy server v1.17.0 | ||||
| Released Mar 11, 2022 | ||||
| 
 | ||||
| **Features & bug fixes:** | ||||
| 
 | ||||
| * Replace [web app](https://ntfy.sh/app) with a React/MUI-based web app from the 21st century (#111) | ||||
| * Web UI broken with auth (#132, thanks for reporting @arminus) | ||||
| * Send static web resources as `Content-Encoding: gzip`, i.e. docs and web app (no ticket) | ||||
| * Add support for auth via `?auth=...` query param, used by WebSocket in web app (no ticket)  | ||||
| 
 | ||||
| ## ntfy server v1.16.0 | ||||
| Released Feb 27, 2022 | ||||
| 
 | ||||
| **Features & Bug fixes:** | ||||
| 
 | ||||
| * Add [auth support](https://ntfy.sh/docs/subscribe/cli/#authentication) for subscribing with CLI (#147/#148, thanks @lrabane) | ||||
| * Add support for [?since=<id>](https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages) (#151, thanks for reporting @nachotp) | ||||
| 
 | ||||
| **Documentation:** | ||||
| 
 | ||||
| * Add [watchtower/shoutrr examples](https://ntfy.sh/docs/examples/#watchtower-notifications-shoutrrr) (#150, thanks @rogeliodh) | ||||
| * Add [release notes](https://ntfy.sh/docs/releases/) | ||||
| 
 | ||||
| **Technical notes:** | ||||
| 
 | ||||
| * As of this release, message IDs will be 12 characters long (as opposed to 10 characters). This is to be able to  | ||||
|   distinguish them from Unix timestamps for #151. | ||||
| 
 | ||||
| ## ntfy Android app v1.9.1 | ||||
| Released Feb 16, 2022 | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * Share to topic feature (#131, thanks u/emptymatrix for reporting) | ||||
| * Ability to pick a default server (#127, thanks to @poblabs for reporting and testing) | ||||
| * Automatically delete notifications (#71, thanks @arjan-s for reporting) | ||||
| * Dark theme: Improvements around style and contrast (#119, thanks @kzshantonu for reporting) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Do not attempt to download attachments if they are already expired (#135) | ||||
| * Fixed crash in AddFragment as seen per stack trace in Play Console (no ticket) | ||||
| 
 | ||||
| **Other thanks:** | ||||
| 
 | ||||
| * Thanks to @rogeliodh, @cmeis and @poblabs for testing | ||||
| 
 | ||||
| ## ntfy server v1.15.0 | ||||
| Released Feb 14, 2022 | ||||
| 
 | ||||
| **Features & bug fixes:** | ||||
| 
 | ||||
| * Compress binaries with `upx` (#137) | ||||
| * Add `visitor-request-limit-exempt-hosts` to exempt friendly hosts from rate limits (#144) | ||||
| * Double default requests per second limit from 1 per 10s to 1 per 5s (no ticket) | ||||
| * Convert `\n` to new line for `X-Message` header as prep for sharing feature (see #136) | ||||
| * Reduce bcrypt cost to 10 to make auth timing more reasonable on slow servers (no ticket) | ||||
| * Docs update to include [public test topics](https://ntfy.sh/docs/publish/#public-topics) (no ticket) | ||||
| 
 | ||||
| ## ntfy server v1.14.1 | ||||
| Released Feb 9, 2022 | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Fix ARMv8 Docker build (#113, thanks to @djmaze) | ||||
| * No other significant changes | ||||
| 
 | ||||
| ## ntfy Android app v1.8.1 | ||||
| Released Feb 6, 2022 | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * Support [auth / access control](https://ntfy.sh/docs/config/#access-control) (#19, thanks to @cmeis, @drsprite/@poblabs,  | ||||
|   @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher) | ||||
| * Export/upload log now allows censored/uncensored logs (no ticket) | ||||
| * Removed wake lock (except for notification dispatching, no ticket) | ||||
| * Swipe to remove notifications (#117) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob) | ||||
| * Fix for Android 12 crashes (#124, thanks @eskilop) | ||||
| * Fix WebSocket retry logic bug with multiple servers (no ticket) | ||||
| * Fix race in refresh logic leading to duplicate connections (no ticket) | ||||
| * Fix scrolling issue in subscribe to topic dialog (#131, thanks @arminus) | ||||
| * Fix base URL text field color in dark mode, and size with large fonts (no ticket) | ||||
| * Fix action bar color in dark mode (make black, no ticket) | ||||
| 
 | ||||
| **Notes:** | ||||
| 
 | ||||
| * Foundational work for per-subscription settings | ||||
| 
 | ||||
| ## ntfy server v1.14.0 | ||||
| Released Feb 3, 2022 | ||||
| 
 | ||||
| **Features**: | ||||
| 
 | ||||
| * Server-side for [authentication & authorization](https://ntfy.sh/docs/config/#access-control) (#19, thanks for testing @cmeis, and for input from @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher) | ||||
| * Support `NTFY_TOPIC` env variable in `ntfy publish` (#103) | ||||
| 
 | ||||
| **Bug fixes**: | ||||
| 
 | ||||
| * Binary UnifiedPush messages should not be converted to attachments (part 1, #101) | ||||
| 
 | ||||
| **Docs**: | ||||
| 
 | ||||
| * Clarification regarding attachments (#118, thanks @xnumad) | ||||
| 
 | ||||
| ## ntfy Android app v1.7.1 | ||||
| Released Jan 21, 2022 | ||||
| 
 | ||||
| **New features:** | ||||
| 
 | ||||
| * Battery improvements: wakelock disabled by default (#76) | ||||
| * Dark mode: Allow changing app appearance (#102) | ||||
| * Report logs: Copy/export logs to help troubleshooting (#94) | ||||
| * WebSockets (experimental): Use WebSockets to subscribe to topics (#96, #100, #97) | ||||
| * Show battery optimization banner (#105) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * (Partial) support for binary UnifiedPush messages (#101) | ||||
| 
 | ||||
| **Notes:** | ||||
| 
 | ||||
| * The foreground wakelock is now disabled by default | ||||
| * The service restarter is now scheduled every 3h instead of every 6h | ||||
| 
 | ||||
| ## ntfy server v1.13.0 | ||||
| Released Jan 16, 2022 | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * [Websockets](https://ntfy.sh/docs/subscribe/api/#websockets) endpoint | ||||
| * Listen on Unix socket, see [config option](https://ntfy.sh/docs/config/#config-options) `listen-unix` | ||||
| 
 | ||||
| ## ntfy Android app v1.6.0 | ||||
| Released Jan 14, 2022 | ||||
| 
 | ||||
| **New features:** | ||||
| 
 | ||||
| * Attachments: Send files to the phone (#25, #15) | ||||
| * Click action: Add a click action URL to notifications (#85) | ||||
| * Battery optimization: Allow disabling persistent wake-lock (#76, thanks @MatMaul) | ||||
| * Recognize imported user CA certificate for self-hosted servers (#87, thanks @keith24) | ||||
| * Remove mentions of "instant delivery" from F-Droid to make it less confusing (no ticket) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Subscription "muted until" was not always respected (#90) | ||||
| * Fix two stack traces reported by Play console vitals (no ticket) | ||||
| * Truncate FCM messages >4,000 bytes, prefer instant messages (#84) | ||||
| 
 | ||||
| ## ntfy server v1.12.1 | ||||
| Released Jan 14, 2022 | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Fix security issue with attachment peaking (#93) | ||||
| 
 | ||||
| ## ntfy server v1.12.0 | ||||
| Released Jan 13, 2022 | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * [Attachments](https://ntfy.sh/docs/publish/#attachments) (#25, #15) | ||||
| * [Click action](https://ntfy.sh/docs/publish/#click-action) (#85) | ||||
| * Increase FCM priority for high/max priority messages (#70) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Make postinst script work properly for rpm-based systems (#83, thanks @cmeis) | ||||
| * Truncate FCM messages longer than 4000 bytes (#84) | ||||
| * Fix `listen-https` port (no ticket) | ||||
| 
 | ||||
| ## ntfy Android app v1.5.2 | ||||
| Released Jan 3, 2022 | ||||
| 
 | ||||
| **New features:** | ||||
| 
 | ||||
| * Allow using ntfy as UnifiedPush distributor (#9) | ||||
| * Support for longer message up to 4096 bytes (#77) | ||||
| * Minimum priority: show notifications only if priority X or higher (#79) | ||||
| * Allowing disabling broadcasts in global settings (#80) | ||||
| 
 | ||||
| **Bug fixes:** | ||||
| 
 | ||||
| * Allow int/long extras for SEND_MESSAGE intent (#57) | ||||
| * Various battery improvement fixes (#76) | ||||
| 
 | ||||
| ## ntfy server v1.11.2 | ||||
| Released Jan 1, 2022 | ||||
| 
 | ||||
| **Features & bug fixes:** | ||||
| 
 | ||||
| * Increase message limit to 4096 bytes (4k) #77 | ||||
| * Docs for [UnifiedPush](https://unifiedpush.org) #9 | ||||
| * Increase keepalive interval to 55s #76 | ||||
| * Increase Firebase keepalive to 3 hours #76 | ||||
| 
 | ||||
| ## ntfy server v1.10.0 | ||||
| Released Dec 28, 2021 | ||||
| 
 | ||||
| **Features & bug fixes:** | ||||
| 
 | ||||
| * [Publish messages via e-mail](ntfy.sh/docs/publish/#e-mail-publishing) #66 | ||||
| * Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64 | ||||
| * Fixing the Santa bug #65 | ||||
| 
 | ||||
| ## Older releases | ||||
| For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) | ||||
| and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). | ||||
							
								
								
									
										27
									
								
								docs/static/css/extra.css
									
										
									
									
										vendored
									
									
								
							
							
						
						|  | @ -1,16 +1,39 @@ | |||
| :root { | ||||
|     --md-primary-fg-color:        #338574; | ||||
|     --md-primary-fg-color--light: #338574; | ||||
|     --md-primary-fg-color--dark:  #338574; | ||||
| } | ||||
| 
 | ||||
| .md-header__button.md-logo :is(img, svg) { | ||||
|     width: unset !important; | ||||
| } | ||||
| 
 | ||||
| .md-typeset h4 { | ||||
|     font-weight: 500 !important; | ||||
|     margin: 0 !important; | ||||
|     font-size: 1.1em !important; | ||||
| } | ||||
| 
 | ||||
| .admonition { | ||||
|     font-size: .74rem !important; | ||||
| } | ||||
| 
 | ||||
| article { | ||||
|     padding-bottom: 50px; | ||||
| } | ||||
| 
 | ||||
| figure iframe, figure img, figure video { | ||||
|     filter: drop-shadow(3px 3px 3px #ccc); | ||||
| figure img, figure video { | ||||
|     border-radius: 7px; | ||||
| } | ||||
| 
 | ||||
| body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video { | ||||
|     filter: drop-shadow(3px 3px 3px #ccc); | ||||
| } | ||||
| 
 | ||||
| body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video { | ||||
|     filter: drop-shadow(3px 3px 3px #1a1313); | ||||
| } | ||||
| 
 | ||||
| figure video { | ||||
|     width: 100%; | ||||
|     max-height: 450px; | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-notification-settings.png
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 49 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-add-instant.jpg
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 297 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-add-instant.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 93 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-add-other.jpg
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 300 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-add-other.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 96 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-add.jpg
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 236 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-add.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 77 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-attachment-file.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 52 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-attachment-image.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 156 KiB | 
| Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-detail.jpg
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 255 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-detail.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 74 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-macrodroid-action.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 102 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-macrodroid-overview.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 90 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-macrodroid-send-action.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 83 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-macrodroid-send-macro.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 77 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-macrodroid-trigger.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 95 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-main.jpg
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 149 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-main.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 53 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-muted.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-pause.jpg
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 212 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-pause.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 74 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-action-edit.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 60 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-action-http-post.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 110 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-event-edit.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 74 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-profile-send.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 51 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-profiles.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 56 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-task-edit-post.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 48 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-tasker-task-edit.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 55 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 59 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-unifiedpush-settings.jpg
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 66 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-unifiedpush-subscription.jpg
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 42 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/cli-subscribe-video-1.mp4
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/cli-subscribe-video-2.webm
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/cli-subscribe-video-3.webm
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/overview.gif
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 3.7 MiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/overview.mp4
									
										
									
									
										vendored
									
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/screenshot-email-publishing-dns.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/screenshot-email-publishing-gmail.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 29 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/screenshot-email.png
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 49 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/web-detail.png
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 473 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/web-subscribe.png
									
										
									
									
										vendored
									
									
								
							
							
						
						| Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 76 KiB | 
|  | @ -1,9 +1,13 @@ | |||
| # Subscribe via API | ||||
| You can create and subscribe to a topic either in the [web UI](web.md), via the [phone app](phone.md), or in your own  | ||||
| app or script by subscribing the API. This page describes how to subscribe via API. You may also want to check out the  | ||||
| page that describes how to [publish messages](../publish.md). | ||||
| You can create and subscribe to a topic in the [web UI](web.md), via the [phone app](phone.md), via the [ntfy CLI](cli.md), | ||||
| or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to  | ||||
| check out the page that describes how to [publish messages](../publish.md). | ||||
| 
 | ||||
| The subscription API relies on a simple HTTP GET request with a streaming HTTP response, i.e **you open a GET request and | ||||
| You can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or  | ||||
| **[via WebSockets](#websockets)**. Both are incredibly simple to use. | ||||
| 
 | ||||
| ## HTTP stream | ||||
| The HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and | ||||
| the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which  | ||||
| only differ in the response format: | ||||
| 
 | ||||
|  | @ -12,7 +16,7 @@ only differ in the response format: | |||
|   can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) | ||||
| * [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message | ||||
| 
 | ||||
| ## Subscribe as JSON stream | ||||
| ### Subscribe as JSON stream | ||||
| Here are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the  | ||||
| recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the  | ||||
| [SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with. | ||||
|  | @ -26,6 +30,13 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript, | |||
|     ... | ||||
|     ``` | ||||
| 
 | ||||
| === "ntfy CLI" | ||||
|     ``` | ||||
|     $ ntfy subcribe disk-alerts | ||||
|     {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Disk full"} | ||||
|     ... | ||||
|     ``` | ||||
| 
 | ||||
| === "HTTP" | ||||
|     ``` http | ||||
|     GET /disk-alerts/json HTTP/1.1 | ||||
|  | @ -54,6 +65,14 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript, | |||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| === "Python" | ||||
|     ``` python | ||||
|     resp = requests.get("https://ntfy.sh/disk-alerts/json", stream=True) | ||||
|     for line in resp.iter_lines(): | ||||
|       if line: | ||||
|         print(line) | ||||
|     ``` | ||||
| 
 | ||||
| === "PHP" | ||||
|     ``` php-inline | ||||
|     $fp = fopen('https://ntfy.sh/disk-alerts/json', 'r'); | ||||
|  | @ -65,7 +84,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript, | |||
|     fclose($fp); | ||||
|     ``` | ||||
| 
 | ||||
| ## Subscribe as SSE stream | ||||
| ### Subscribe as SSE stream | ||||
| Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume | ||||
| notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly  | ||||
| easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html). | ||||
|  | @ -110,7 +129,7 @@ easy to use. Here's what it looks like. You may also want to check out the [live | |||
|     }; | ||||
|     ``` | ||||
| 
 | ||||
| ## Subscribe as raw stream | ||||
| ### Subscribe as raw stream | ||||
| The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely | ||||
| simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),  | ||||
| [tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output  | ||||
|  | @ -150,6 +169,14 @@ format. Keepalive messages are sent as empty lines. | |||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| === "Python" | ||||
|     ``` python  | ||||
|     resp = requests.get("https://ntfy.sh/disk-alerts/raw", stream=True) | ||||
|     for line in resp.iter_lines(): | ||||
|       if line: | ||||
|         print(line) | ||||
|     ``` | ||||
| 
 | ||||
| === "PHP" | ||||
|     ``` php-inline | ||||
|     $fp = fopen('https://ntfy.sh/disk-alerts/raw', 'r'); | ||||
|  | @ -161,37 +188,180 @@ format. Keepalive messages are sent as empty lines. | |||
|     fclose($fp); | ||||
|     ``` | ||||
| 
 | ||||
| ## WebSockets | ||||
| You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely  | ||||
| supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line,  | ||||
| I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically | ||||
| for WebSockets.   | ||||
| 
 | ||||
| The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the  | ||||
| [JSON stream endpoint](#subscribe-as-json-stream).  | ||||
| 
 | ||||
| === "Command line (websocat)" | ||||
|     ``` | ||||
|     $ websocat wss://ntfy.sh/mytopic/ws | ||||
|     {"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"} | ||||
|     {"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"} | ||||
|     ``` | ||||
| 
 | ||||
| === "HTTP" | ||||
|     ``` http | ||||
|     GET /disk-alerts/ws HTTP/1.1 | ||||
|     Host: ntfy.sh | ||||
|     Upgrade: websocket | ||||
|     Connection: Upgrade | ||||
| 
 | ||||
|     HTTP/1.1 101 Switching Protocols | ||||
|     Upgrade: websocket | ||||
|     Connection: Upgrade | ||||
|     ... | ||||
|     ``` | ||||
| 
 | ||||
| === "Go" | ||||
|     ``` go | ||||
|     import "github.com/gorilla/websocket" | ||||
| 	ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil) | ||||
| 	messageType, data, err := ws.ReadMessage() | ||||
|     ... | ||||
|     ``` | ||||
| 
 | ||||
| === "JavaScript" | ||||
|     ``` javascript | ||||
|     const socket = new WebSocket('wss://ntfy.sh/mytopic/ws'); | ||||
|     socket.addEventListener('message', function (event) { | ||||
|         console.log(event.data); | ||||
|     }); | ||||
|     ``` | ||||
| 
 | ||||
| ## Advanced features | ||||
| 
 | ||||
| ### Poll for messages | ||||
| You can also just poll for messages if you don't like the long-standing connection using the `poll=1` | ||||
| query parameter. The connection will end after all available messages have been read. This parameter can be | ||||
| combined with `since=` (defaults to `since=all`). | ||||
| 
 | ||||
| ``` | ||||
| curl -s "ntfy.sh/mytopic/json?poll=1" | ||||
| ``` | ||||
| 
 | ||||
| ### Fetch cached messages | ||||
| Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network | ||||
| interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using  | ||||
| the `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`), | ||||
| a message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages). | ||||
| 
 | ||||
| ``` | ||||
| curl -s "ntfy.sh/mytopic/json?since=10m" | ||||
| curl -s "ntfy.sh/mytopic/json?since=1645970742" | ||||
| curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe" | ||||
| ``` | ||||
| 
 | ||||
| ### Fetch scheduled messages | ||||
| Messages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically  | ||||
| returned when subscribing via the API, which makes sense, because after all, the messages have technically not been  | ||||
| delivered yet. To also return scheduled messages from the API, you can use the `scheduled=1` (alias: `sched=1`)  | ||||
| parameter (makes most sense with the `poll=1` parameter): | ||||
| 
 | ||||
| ``` | ||||
| curl -s "ntfy.sh/mytopic/json?poll=1&sched=1" | ||||
| ``` | ||||
| 
 | ||||
| ### Filter messages | ||||
| You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and | ||||
| `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.  | ||||
| 
 | ||||
| ``` | ||||
| $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error" | ||||
| {"id":"0TIkJpBcxR","time":1640122627,"event":"open","topic":"alerts"} | ||||
| {"id":"X3Uzz9O1sM","time":1640122674,"event":"message","topic":"alerts","priority":4, | ||||
|   "tags":["error", "zfs-error"], "message":"ZFS pool corruption detected"} | ||||
| ``` | ||||
| 
 | ||||
| Available filters (all case-insensitive): | ||||
| 
 | ||||
| | Filter variable | Alias                     | Example                            | Description                                                             | | ||||
| |-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------| | ||||
| | `message`       | `X-Message`, `m`          | `ntfy.sh/mytopic?message=lalala`   | Only return messages that match this exact message string               | | ||||
| | `title`         | `X-Title`, `t`            | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string                 | | ||||
| | `priority`      | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent`    | Only return messages that match *any priority listed* (comma-separated) | | ||||
| | `tags`          | `X-Tags`, `tag`, `ta`     | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated)     | | ||||
| 
 | ||||
| ### Subscribe to multiple topics | ||||
| It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics  | ||||
| in the URL. This allows you to reduce the number of connections you have to maintain: | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s ntfy.sh/mytopic1,mytopic2/json | ||||
| {"id":"0OkXIryH3H","time":1637182619,"event":"open","topic":"mytopic1,mytopic2,mytopic3"} | ||||
| {"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"} | ||||
| {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} | ||||
| ``` | ||||
| 
 | ||||
| ### Authentication | ||||
| Depending on whether the server is configured to support [access control](../config.md#access-control), some topics | ||||
| may be read/write protected so that only users with the correct credentials can subscribe or publish to them. | ||||
| To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) | ||||
| with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing | ||||
| your password. | ||||
| 
 | ||||
| ``` | ||||
| curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json" | ||||
| ``` | ||||
| 
 | ||||
| ## JSON message format | ||||
| Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON | ||||
| format of the message. It's very straight forward: | ||||
| 
 | ||||
| **Message**: | ||||
| 
 | ||||
| | Field        | Required | Type                                              | Example               | Description                                                                                                                          | | ||||
| |---|---|---|---|---| | ||||
| |--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| | ||||
| | `id`         | ✔️       | *string*                                          | `hwQ2YpKdmg`          | Randomly chosen message identifier                                                                                                   | | ||||
| | `time` | ✔️ | *int* | `1635528741` | Message date time, as Unix time stamp |   | ||||
| | `event` | ✔️ | `open`, `keepalive` or `message` | `message` | Message type, typically you'd be only interested in `message` | | ||||
| | `time`       | ✔️       | *number*                                          | `1635528741`          | Message date time, as Unix time stamp                                                                                                |   | ||||
| | `event`      | ✔️       | `open`, `keepalive`, `message`, or `poll_request` | `message`             | Message type, typically you'd be only interested in `message`                                                                        | | ||||
| | `topic`      | ✔️       | *string*                                          | `topic1,topic2`       | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | | ||||
| | `message`    | -        | *string*                                          | `Some message`        | Message body; always present in `message` events                                                                                     | | ||||
| | `title`      | -        | *string*                                          | `Some title`          | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>`                                               | | ||||
| | `tags`       | -        | *string array*                                    | `["tag1","tag2"]`     | List of [tags](../publish.md#tags-emojis) that may or not map to emojis                                                              | | ||||
| | `priority`   | -        | *1, 2, 3, 4, or 5*                                | `4`                   | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max                                                   | | ||||
| | `click`      | -        | *URL*                                             | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action)                                                            | | ||||
| | `attachment` | -        | *JSON object*                                     | *see below*           | Details about an attachment (name, URL, size, ...)                                                                                   | | ||||
| 
 | ||||
| **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): | ||||
| 
 | ||||
| | Field     | Required | Type        | Example                        | Description                                                                                               | | ||||
| |-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------| | ||||
| | `name`    | ✔️       | *string*    | `attachment.jpg`               | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) | | ||||
| | `url`     | ✔️       | *URL*       | `https://example.com/file.jpg` | URL of the attachment                                                                                     |   | ||||
| | `type`    | -️       | *mime type* | `image/jpeg`                   | Mime type of the attachment, only defined if attachment was uploaded to ntfy server                       | | ||||
| | `size`    | -️       | *number*    | `33848`                        | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server                   | | ||||
| | `expires` | -️       | *number*    | `1635528741`                   | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server         | | ||||
| 
 | ||||
| Here's an example for each message type: | ||||
| 
 | ||||
| === "Notification message" | ||||
|     ``` json | ||||
|     { | ||||
|       "id": "wze9zgqK41", | ||||
|       "time": 1638542110, | ||||
|         "id": "sPs71M8A2T", | ||||
|         "time": 1643935928, | ||||
|         "event": "message", | ||||
|       "topic": "phil_alerts", | ||||
|         "topic": "mytopic", | ||||
|         "priority": 5, | ||||
|         "tags": [ | ||||
|             "warning", | ||||
|             "skull" | ||||
|         ], | ||||
|         "click": "https://homecam.mynet.lan/incident/1234", | ||||
|         "attachment": { | ||||
|             "name": "camera.jpg", | ||||
|             "type": "image/png", | ||||
|             "size": 33848, | ||||
|             "expires": 1643946728, | ||||
|             "url": "https://ntfy.sh/file/sPs71M8A2T.png" | ||||
|         }, | ||||
|         "title": "Unauthorized access detected", | ||||
|       "message": "Remote access to phils-laptop detected. Act right away." | ||||
|         "message": "Movement detected in the yard. You better go check" | ||||
|     } | ||||
|     ``` | ||||
| 
 | ||||
|  | @ -227,34 +397,26 @@ Here's an example for each message type: | |||
|     } | ||||
|     ```     | ||||
| 
 | ||||
| ## Advanced features | ||||
| 
 | ||||
| ### Fetching cached messages | ||||
| Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network | ||||
| interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using  | ||||
| the `since=` query parameter. It takes either a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`)  | ||||
| or `all` (all cached messages). | ||||
| 
 | ||||
| ``` | ||||
| curl -s "ntfy.sh/mytopic/json?since=10m" | ||||
| === "Poll request message" | ||||
|     ``` json | ||||
|     { | ||||
|         "id": "371sevb0pD", | ||||
|         "time": 1638542275, | ||||
|         "event": "poll_request", | ||||
|         "topic": "phil_alerts" | ||||
|     } | ||||
|     ``` | ||||
| 
 | ||||
| ### Polling | ||||
| You can also just poll for messages if you don't like the long-standing connection using the `poll=1` | ||||
| query parameter. The connection will end after all available messages have been read. This parameter can be | ||||
| combined with `since=` (defaults to `since=all`). | ||||
| ## List of all parameters | ||||
| The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**, | ||||
| and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form. | ||||
| 
 | ||||
| ``` | ||||
| curl -s "ntfy.sh/mytopic/json?poll=1" | ||||
| ``` | ||||
| 
 | ||||
| ### Subscribing to multiple topics | ||||
| It's possible to subscribe to multiple topics in one HTTP call by providing a | ||||
| comma-separated list of topics in the URL. This allows you to reduce the number of connections you have to maintain: | ||||
| 
 | ||||
| ``` | ||||
| $ curl -s ntfy.sh/mytopic1,mytopic2/json | ||||
| {"id":"0OkXIryH3H","time":1637182619,"event":"open","topic":"mytopic1,mytopic2,mytopic3"} | ||||
| {"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"} | ||||
| {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} | ||||
| ``` | ||||
| | Parameter   | Aliases (case-insensitive) | Description                                                                     | | ||||
| |-------------|----------------------------|---------------------------------------------------------------------------------| | ||||
| | `poll`      | `X-Poll`, `po`             | Return cached messages and close connection                                     | | ||||
| | `since`     | `X-Since`, `si`            | Return cached messages since timestamp, duration or message ID                  | | ||||
| | `scheduled` | `X-Scheduled`, `sched`     | Include scheduled/delayed messages in message list                              | | ||||
| | `message`   | `X-Message`, `m`           | Filter: Only return messages that match this exact message string               | | ||||
| | `title`     | `X-Title`, `t`             | Filter: Only return messages that match this exact title string                 | | ||||
| | `priority`  | `X-Priority`, `prio`, `p`  | Filter: Only return messages that match *any priority listed* (comma-separated) | | ||||
| | `tags`      | `X-Tags`, `tag`, `ta`      | Filter: Only return messages that match *all listed tags* (comma-separated)     | | ||||
|  |  | |||
							
								
								
									
										222
									
								
								docs/subscribe/cli.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,222 @@ | |||
| # Subscribe via ntfy CLI | ||||
| In addition to subscribing via the [web UI](web.md), the [phone app](phone.md), or the [API](api.md), you can subscribe | ||||
| to topics via the ntfy CLI. The CLI is included in the same `ntfy` binary that can be used to [self-host a server](../install.md). | ||||
| 
 | ||||
| !!! info | ||||
|     The **ntfy CLI is not required to send or receive messages**. You can instead [send messages with curl](../publish.md), | ||||
|     and even use it to [subscribe to topics](api.md). It may be a little more convenient to use the ntfy CLI than writing  | ||||
|     your own script. It all depends on the use case. 😀 | ||||
| 
 | ||||
| ## Install + configure | ||||
| To install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and  | ||||
| client are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client  | ||||
| by creating `~/.config/ntfy/client.yml` (for the non-root user), or `/etc/ntfy/client.yml` (for the root user). You  | ||||
| can find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub.  | ||||
| 
 | ||||
| If you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**, | ||||
| you may want to edit the `default-host` option: | ||||
| 
 | ||||
| ``` yaml | ||||
| # Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands. | ||||
| # If you self-host a ntfy server, you'll likely want to change this. | ||||
| # | ||||
| default-host: https://ntfy.myhost.com | ||||
| ``` | ||||
| 
 | ||||
| ## Publish messages | ||||
| You can send messages with the ntfy CLI using the `ntfy publish` command (or any of its aliases `pub`, `send` or  | ||||
| `trigger`). There are a lot of examples on the page about [publishing messages](../publish.md), but here are a few | ||||
| quick ones: | ||||
| 
 | ||||
| === "Simple send" | ||||
|     ``` | ||||
|     ntfy publish mytopic This is a message | ||||
|     ntfy publish mytopic "This is a message" | ||||
|     ntfy pub mytopic "This is a message"  | ||||
|     ``` | ||||
| 
 | ||||
| === "Send with title, priority, and tags" | ||||
|     ``` | ||||
|     ntfy publish \ | ||||
|         --title="Thing sold on eBay" \ | ||||
|         --priority=high \ | ||||
|         --tags=partying_face \ | ||||
|         mytopic \ | ||||
|         "Somebody just bought the thing that you sell" | ||||
|     ``` | ||||
| 
 | ||||
| === "Send at 8:30am" | ||||
|     ``` | ||||
|     ntfy pub --at=8:30am delayed_topic Laterzz | ||||
|     ``` | ||||
| 
 | ||||
| === "Triggering a webhook" | ||||
|     ``` | ||||
|     ntfy trigger mywebhook | ||||
|     ntfy pub mywebhook | ||||
|     ``` | ||||
| 
 | ||||
| ## Subscribe to topics | ||||
| You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command | ||||
| will either print or execute a command for every arriving message. There are a few different ways  | ||||
| in which the command can be run: | ||||
| 
 | ||||
| ### Stream messages as JSON | ||||
| ``` | ||||
| ntfy subscribe TOPIC | ||||
| ``` | ||||
| If you run the command like this, it prints the JSON representation of every incoming message. This is useful  | ||||
| when you have a command that wants to stream-read incoming JSON messages. Unless `--poll` is passed, this command  | ||||
| stays open forever. | ||||
| 
 | ||||
| ``` | ||||
| $ ntfy sub mytopic | ||||
| {"id":"nZ8PjH5oox","time":1639971913,"event":"message","topic":"mytopic","message":"hi there"} | ||||
| {"id":"sekSLWTujn","time":1639972063,"event":"message","topic":"mytopic",priority:5,"message":"Oh no!"} | ||||
| ... | ||||
| ``` | ||||
| 
 | ||||
| <figure> | ||||
|   <video controls muted autoplay loop width="650" src="../../static/img/cli-subscribe-video-1.mp4"></video> | ||||
|   <figcaption>Subscribe in JSON mode</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| ### Run command for every message | ||||
| ``` | ||||
| ntfy subscribe TOPIC COMMAND | ||||
| ``` | ||||
| If you run it like this, a COMMAND is executed for every incoming messages. Scroll down to see a list of available | ||||
| environment variables. Here are a few examples: | ||||
|   | ||||
| ``` | ||||
| ntfy sub mytopic 'notify-send "$m"' | ||||
| ntfy sub topic1 /my/script.sh | ||||
| ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p' | ||||
| ``` | ||||
| 
 | ||||
| <figure> | ||||
|   <video controls muted autoplay loop width="650" src="../../static/img/cli-subscribe-video-2.webm"></video> | ||||
|   <figcaption>Execute command on incoming messages</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| The message fields are passed to the command as environment variables and can be used in scripts. Note that since  | ||||
| these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them | ||||
| in double-quotes, you should be fine: | ||||
| 
 | ||||
| | Variable         | Aliases                    | Description                            | | ||||
| |------------------|----------------------------|----------------------------------------| | ||||
| | `$NTFY_ID`       | `$id`                      | Unique message ID                      | | ||||
| | `$NTFY_TIME`     | `$time`                    | Unix timestamp of the message delivery | | ||||
| | `$NTFY_TOPIC`    | `$topic`                   | Topic name                             | | ||||
| | `$NTFY_MESSAGE`  | `$message`, `$m`           | Message body                           | | ||||
| | `$NTFY_TITLE`    | `$title`, `$t`             | Message title                          | | ||||
| | `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max)        | | ||||
| | `$NTFY_TAGS`     | `$tags`, `$tag`, `$ta`     | Message tags (comma separated list)    | | ||||
| | `$NTFY_RAW`      | `$raw`                     | Raw JSON message                       | | ||||
|     | ||||
| ### Subscribe to multiple topics | ||||
| ``` | ||||
| ntfy subscribe --from-config | ||||
| ``` | ||||
| To subscribe to multiple topics at once, and run different commands for each one, you can use `ntfy subscribe --from-config`, | ||||
| which will read the `subscribe` config from the config file. Please also check out the [ntfy-client systemd service](#using-the-systemd-service). | ||||
| 
 | ||||
| Here's an example config file that subscribes to three different topics, executing a different command for each of them: | ||||
| 
 | ||||
| === "~/.config/ntfy/client.yml" | ||||
|     ```yaml | ||||
|     subscribe: | ||||
|     - topic: echo-this | ||||
|       command: 'echo "Message received: $message"' | ||||
|     - topic: alerts | ||||
|       command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m" | ||||
|       if: | ||||
|         priority: high,urgent | ||||
|     - topic: calc | ||||
|       command: 'gnome-calculator 2>/dev/null &' | ||||
|     - topic: print-temp | ||||
|       command: | | ||||
|             echo "You can easily run inline scripts, too." | ||||
|             temp="$(sensors | awk '/Pack/ { print substr($4,2,2) }')" | ||||
|             if [ $temp -gt 80 ]; then | ||||
|               echo "Warning: CPU temperature is $temp. Too high." | ||||
|             else | ||||
|               echo "CPU temperature is $temp. That's alright." | ||||
|             fi | ||||
|     ``` | ||||
| 
 | ||||
| In this example, when `ntfy subscribe --from-config` is executed: | ||||
| 
 | ||||
| * Messages to `echo-this` simply echos to standard out | ||||
| * Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) | ||||
| * Messages to `calc` open the gnome calculator 😀 (*because, why not*) | ||||
| * Messages to `print-temp` execute an inline script and print the CPU temperature | ||||
| 
 | ||||
| I hope this shows how powerful this command is. Here's a short video that demonstrates the above example: | ||||
| 
 | ||||
| <figure> | ||||
|   <video controls muted autoplay loop width="650" src="../../static/img/cli-subscribe-video-3.webm"></video> | ||||
|   <figcaption>Execute all the things</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| ### Using the systemd service | ||||
| You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service)) | ||||
| to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started) | ||||
| if you install the deb/rpm package. To configure it, simply edit `/etc/ntfy/client.yml` and run `sudo systemctl restart ntfy-client`. | ||||
| 
 | ||||
| !!! info | ||||
|     The `ntfy-client.service` runs as user `ntfy`, meaning that typical Linux permission restrictions apply. See below | ||||
|     for how to fix this. | ||||
| 
 | ||||
| If the service runs on your personal desktop machine, you may want to override the service user/group (`User=` and `Group=`), and  | ||||
| adjust the `DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` environment variables. This will allow you to run commands in your X session  | ||||
| as the primary machine user. | ||||
| 
 | ||||
| You can either manually override these systemd service entries with `sudo systemctl edit ntfy-client`, and add this | ||||
| (assuming your user is `phil`). Don't forget to run `sudo systemctl daemon-reload` and `sudo systemctl restart ntfy-client` | ||||
| after editing the service file: | ||||
| 
 | ||||
| === "/etc/systemd/system/ntfy-client.service.d/override.conf" | ||||
|     ``` | ||||
|     [Service] | ||||
|     User=phil | ||||
|     Group=phil | ||||
|     Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" | ||||
|     ``` | ||||
| Or you can run the following script that creates this override config for you: | ||||
| 
 | ||||
| ``` | ||||
| sudo sh -c 'cat > /etc/systemd/system/ntfy-client.service.d/override.conf' <<EOF | ||||
| [Service] | ||||
| User=$USER | ||||
| Group=$USER | ||||
| Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" | ||||
| EOF | ||||
| 
 | ||||
| sudo systemctl daemon-reload | ||||
| sudo systemctl restart ntfy-client | ||||
| ``` | ||||
| 
 | ||||
| 
 | ||||
| ### Authentication | ||||
| Depending on whether the server is configured to support [access control](../config.md#access-control), some topics | ||||
| may be read/write protected so that only users with the correct credentials can subscribe or publish to them. | ||||
| To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) | ||||
| with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing | ||||
| your password.  | ||||
| 
 | ||||
| You can either add your username and password to the configuration file: | ||||
| === "~/.config/ntfy/client.yml" | ||||
| 	```yaml | ||||
| 	 - topic: secret | ||||
| 	   command: 'notify-send "$m"' | ||||
| 	   user: phill | ||||
| 	   password: mypass | ||||
| 	``` | ||||
| 
 | ||||
| Or with the `ntfy subscibe` command: | ||||
| ``` | ||||
| ntfy subscribe \ | ||||
|   -u phil:mypass \ | ||||
|   ntfy.example.com/mysecrets | ||||
| ``` | ||||
|  | @ -3,7 +3,6 @@ You can use the [ntfy Android App](https://play.google.com/store/apps/details?id | |||
| notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android). | ||||
| Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4). | ||||
| 
 | ||||
| ## Android | ||||
| <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> | ||||
| 
 | ||||
|  | @ -11,26 +10,27 @@ You can get the Android app from both [Google Play](https://play.google.com/stor | |||
| 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. | ||||
| 
 | ||||
| ### Overview | ||||
| ## Overview | ||||
| A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty | ||||
| straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them. | ||||
| 
 | ||||
| <div id="android-screenshots" class="screenshots"> | ||||
|     <a href="../../static/img/android-screenshot-main.jpg"><img src="../../static/img/android-screenshot-main.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-detail.jpg"><img src="../../static/img/android-screenshot-detail.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-add.jpg"><img src="../../static/img/android-screenshot-add.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-add-instant.jpg"><img src="../../static/img/android-screenshot-add-instant.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-add-other.jpg"><img src="../../static/img/android-screenshot-add-other.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-main.png"><img src="../../static/img/android-screenshot-main.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-detail.png"><img src="../../static/img/android-screenshot-detail.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-pause.png"><img src="../../static/img/android-screenshot-pause.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-add.png"><img src="../../static/img/android-screenshot-add.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-add-instant.png"><img src="../../static/img/android-screenshot-add-instant.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-add-other.png"><img src="../../static/img/android-screenshot-add-other.png"/></a> | ||||
| </div> | ||||
| 
 | ||||
| If those screenshots are still not enough, here's a video: | ||||
| 
 | ||||
| <figure> | ||||
|   <video controls muted autoplay loop width="650" src="../../static/img/overview.mp4"></video> | ||||
|   <video controls muted autoplay loop width="650" src="../../static/img/android-video-overview.mp4"></video> | ||||
|   <figcaption>Sending push notifications to your Android phone</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| ### Message priority | ||||
| ## Message priority | ||||
| 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. | ||||
| 
 | ||||
|  | @ -50,7 +50,7 @@ the settings (and custom sounds or vibration) for each of the priorities: | |||
|   <figcaption>Per-priority sound/vibration settings</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| ### Instant delivery | ||||
| ## Instant delivery | ||||
| Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.  | ||||
| when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which  | ||||
| you'll see as a permanent notification that looks like this: | ||||
|  | @ -69,8 +69,8 @@ To do so, long-press on the foreground notification (screenshot above) and navig | |||
|   <figcaption>Turning off the persistent instant delivery notification</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| ### Limitations without instant delivery | ||||
| Without instant delivery, **messages may arrive with a significant delay** (sometimes many minutes, or even hours later). If you've ever picked up your phone and  | ||||
| **Limitations without instant delivery**: Without instant delivery, **messages may arrive with a significant delay**  | ||||
| (sometimes many minutes, or even hours later). If you've ever picked up your phone and  | ||||
| suddenly had 10 messages that were sent long before you know what I'm talking about. | ||||
| 
 | ||||
| The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging). FCM is the  | ||||
|  | @ -80,6 +80,101 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu | |||
| The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app. | ||||
| It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor. | ||||
| 
 | ||||
| ## Integrations | ||||
| 
 | ||||
| ### UnifiedPush | ||||
| [UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned | ||||
| [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications  | ||||
| in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.  | ||||
| 
 | ||||
| To use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/).  | ||||
| That's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md) | ||||
| to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/): | ||||
| 
 | ||||
| <div id="unifiedpush-screenshots" class="screenshots"> | ||||
|     <a href="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"><img src="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-unifiedpush-subscription.jpg"><img src="../../static/img/android-screenshot-unifiedpush-subscription.jpg"/></a> | ||||
|     <a href="../../static/img/android-screenshot-unifiedpush-settings.jpg"><img src="../../static/img/android-screenshot-unifiedpush-settings.jpg"/></a> | ||||
| </div> | ||||
| 
 | ||||
| ### Automation apps | ||||
| The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) | ||||
| or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can | ||||
| **react to incoming messages**, as well as **send messages**. | ||||
| 
 | ||||
| #### React to incoming messages | ||||
| To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see | ||||
| [code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)). | ||||
| Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) | ||||
| and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch  | ||||
| broadcasts is supported: | ||||
| 
 | ||||
| <div id="integration-screenshots-receive" class="screenshots"> | ||||
|     <a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-action-edit.png"><img src="../../static/img/android-screenshot-tasker-action-edit.png"/></a> | ||||
| </div> | ||||
| 
 | ||||
| For MacroDroid, be sure to type in the package name `io.heckel.ntfy`, otherwise intents may be silently swallowed. | ||||
| If you're using topics to drive automation, you'll likely want to mute the topic in the ntfy app. This will prevent  | ||||
| notification popups: | ||||
| 
 | ||||
| <figure markdown> | ||||
|   { width=500 } | ||||
|   <figcaption>Muting notifications to prevent popups</figcaption> | ||||
| </figure> | ||||
| 
 | ||||
| Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`: | ||||
| 
 | ||||
| | Extra name      | Type                         | Example            | Description                                                                        | | ||||
| |-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------| | ||||
| | `id`            | *String*                     | `bP8dMjO8ig`       | Randomly chosen message identifier (likely not very useful for task automation)    | | ||||
| | `base_url`      | *String*                     | `https://ntfy.sh`  | Root URL of the ntfy server this message came from                                 | | ||||
| | `topic` ❤️      | *String*                     | `mytopic`          | Topic name; **you'll likely want to filter for a specific topic**                  | | ||||
| | `muted`         | *Boolean*                    | `true`             | Indicates whether the subscription was muted in the app                            | | ||||
| | `muted_str`     | *String (`true` or `false`)* | `true`             | Same as `muted`, but as string `true` or `false`                                   | | ||||
| | `time`          | *Int*                        | `1635528741`       | Message date time, as Unix time stamp                                              | | ||||
| | `title`         | *String*                     | `Some title`       | Message [title](../publish.md#message-title); may be empty if not set              | | ||||
| | `message` ❤️    | *String*                     | `Some message`     | Message body; **this is likely what you're interested in**                         | | ||||
| | `message_bytes` | *ByteArray*                  | `(binary data)`    | Message body as binary data                                                        | | ||||
| | `encoding`️     | *String*                     | -                  | Message encoding (empty or "base64")                                               | | ||||
| | `tags`          | *String*                     | `tag1,tag2,..`     | Comma-separated list of [tags](../publish.md#tags-emojis)                          | | ||||
| | `tags_map`      | *String*                     | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag                        | | ||||
| | `priority`      | *Int (between 1-5)*          | `4`                | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | | ||||
| 
 | ||||
| #### Send messages using intents | ||||
| To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) | ||||
| and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can  | ||||
| broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP | ||||
| POST request to [publish a message](../publish.md). This is primarily useful for apps that do not support HTTP POST/PUT | ||||
| (like MacroDroid). In Tasker, you can simply use the "HTTP Request" action, which is a little easier and also works if  | ||||
| ntfy is not installed. | ||||
| 
 | ||||
| Here's what that looks like: | ||||
| 
 | ||||
| <div id="integration-screenshots-send" class="screenshots"> | ||||
|     <a href="../../static/img/android-screenshot-macrodroid-send-macro.png"><img src="../../static/img/android-screenshot-macrodroid-send-macro.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-macrodroid-send-action.png"><img src="../../static/img/android-screenshot-macrodroid-send-action.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-profile-send.png"><img src="../../static/img/android-screenshot-tasker-profile-send.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-task-edit-post.png"><img src="../../static/img/android-screenshot-tasker-task-edit-post.png"/></a> | ||||
|     <a href="../../static/img/android-screenshot-tasker-action-http-post.png"><img src="../../static/img/android-screenshot-tasker-action-http-post.png"/></a> | ||||
| </div> | ||||
| 
 | ||||
| The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action: | ||||
| 
 | ||||
| | Extra name   | Required | Type                          | Example           | Description                                                                        | | ||||
| |--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------| | ||||
| | `base_url`   | -        | *String*                      | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh`  | | ||||
| | `topic` ❤️   | ✔        | *String*                      | `mytopic`         | Topic name; **you must set this**                                                  | | ||||
| | `title`      | -        | *String*                      | `Some title`      | Message [title](../publish.md#message-title); may be empty if not set              | | ||||
| | `message` ❤️ | ✔        | *String*                      | `Some message`    | Message body; **you must set this**                                                | | ||||
| | `tags`       | -        | *String*                      | `tag1,tag2,..`    | Comma-separated list of [tags](../publish.md#tags-emojis)                          | | ||||
| | `priority`   | -        | *String or Int (between 1-5)* | `4`               | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | | ||||
| 
 | ||||
| ## iPhone/iOS | ||||
| I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app | ||||
| for ntfy, but it's in the works. You can track the status on GitHub. | ||||
|  |  | |||
|  | @ -6,9 +6,9 @@ keep a connection open and listen for incoming notifications. | |||
| To learn how to send messages, check out the [publishing page](../publish.md). | ||||
| 
 | ||||
| <div id="web-screenshots" class="screenshots"> | ||||
|     <a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a> | ||||
|     <a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a> | ||||
|     <a href="../../static/img/web-detail.png"><img src="../../static/img/web-detail.png"/></a>  | ||||
|     <a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a> | ||||
|     <a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a> | ||||
| </div> | ||||
| 
 | ||||
| To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend, | ||||
|  |  | |||
							
								
								
									
										12
									
								
								examples/publish-python/publish.py
									
										
									
									
									
										Executable file
									
								
							
							
						
						|  | @ -0,0 +1,12 @@ | |||
| #!/usr/bin/env python3 | ||||
| 
 | ||||
| import requests | ||||
| 
 | ||||
| resp = requests.get("https://ntfy.sh/mytopic/trigger", | ||||
|     data="Backup successful 😀".encode(encoding='utf-8'), | ||||
|     headers={ | ||||
|         "Priority": "high", | ||||
|         "Tags": "warning,skull", | ||||
|         "Title": "Hello there" | ||||
|     }) | ||||
| resp.raise_for_status() | ||||
							
								
								
									
										8
									
								
								examples/subscribe-python/subscribe.py
									
										
									
									
									
										Executable file
									
								
							
							
						
						|  | @ -0,0 +1,8 @@ | |||
| #!/usr/bin/env python3 | ||||
| 
 | ||||
| import requests | ||||
| 
 | ||||
| resp = requests.get("https://ntfy.sh/mytopic/json", stream=True) | ||||
| for line in resp.iter_lines(): | ||||
|     if line: | ||||
|         print(line) | ||||
							
								
								
									
										53
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						|  | @ -4,39 +4,48 @@ go 1.17 | |||
| 
 | ||||
| require ( | ||||
| 	cloud.google.com/go/firestore v1.6.1 // indirect | ||||
| 	cloud.google.com/go/storage v1.18.2 // indirect | ||||
| 	cloud.google.com/go/storage v1.21.0 // indirect | ||||
| 	firebase.google.com/go v3.13.0+incompatible | ||||
| 	github.com/BurntSushi/toml v0.4.1 // indirect | ||||
| 	github.com/BurntSushi/toml v1.0.0 // indirect | ||||
| 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect | ||||
| 	github.com/mattn/go-sqlite3 v1.14.9 | ||||
| 	github.com/urfave/cli/v2 v2.3.0 | ||||
| 	golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 // indirect | ||||
| 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac | ||||
| 	google.golang.org/api v0.60.0 | ||||
| 	gopkg.in/yaml.v2 v2.4.0 // indirect | ||||
| 	github.com/emersion/go-smtp v0.15.0 | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.0 | ||||
| 	github.com/gorilla/websocket v1.5.0 | ||||
| 	github.com/mattn/go-sqlite3 v1.14.12 | ||||
| 	github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 | ||||
| 	github.com/stretchr/testify v1.7.0 | ||||
| 	github.com/urfave/cli/v2 v2.4.0 | ||||
| 	golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 | ||||
| 	golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect | ||||
| 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c | ||||
| 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 | ||||
| 	golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 | ||||
| 	google.golang.org/api v0.73.0 | ||||
| 	gopkg.in/yaml.v2 v2.4.0 | ||||
| ) | ||||
| 
 | ||||
| require ( | ||||
| 	cloud.google.com/go v0.97.0 // indirect | ||||
| 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect | ||||
| 	github.com/cespare/xxhash v1.1.0 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.1.2 // indirect | ||||
| 	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect | ||||
| 	github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect | ||||
| 	github.com/envoyproxy/go-control-plane v0.10.0 // indirect | ||||
| 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect | ||||
| 	cloud.google.com/go v0.100.2 // indirect | ||||
| 	cloud.google.com/go/compute v1.5.0 // indirect | ||||
| 	cloud.google.com/go/iam v0.3.0 // indirect | ||||
| 	github.com/AlekSi/pointer v1.0.0 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||
| 	github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect | ||||
| 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect | ||||
| 	github.com/golang/protobuf v1.5.2 // indirect | ||||
| 	github.com/google/go-cmp v0.5.6 // indirect | ||||
| 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect | ||||
| 	github.com/google/go-cmp v0.5.7 // indirect | ||||
| 	github.com/googleapis/gax-go/v2 v2.2.0 // indirect | ||||
| 	github.com/pkg/errors v0.9.1 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||||
| 	go.opencensus.io v0.23.0 // indirect | ||||
| 	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect | ||||
| 	golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 // indirect | ||||
| 	golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect | ||||
| 	golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect | ||||
| 	golang.org/x/text v0.3.7 // indirect | ||||
| 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect | ||||
| 	google.golang.org/appengine v1.6.7 // indirect | ||||
| 	google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145 // indirect | ||||
| 	google.golang.org/grpc v1.41.0 // indirect | ||||
| 	google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect | ||||
| 	google.golang.org/grpc v1.45.0 // indirect | ||||
| 	google.golang.org/protobuf v1.27.1 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect | ||||
| ) | ||||
|  |  | |||
							
								
								
									
										172
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						|  | @ -24,20 +24,29 @@ cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWc | |||
| cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= | ||||
| cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= | ||||
| cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= | ||||
| cloud.google.com/go v0.97.0 h1:3DXvAyifywvq64LfkKaMOmkWPS1CikIQdMe2lY9vxU8= | ||||
| cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= | ||||
| cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= | ||||
| cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= | ||||
| cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y= | ||||
| cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= | ||||
| cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= | ||||
| cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= | ||||
| cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= | ||||
| cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= | ||||
| cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= | ||||
| cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= | ||||
| cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= | ||||
| cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= | ||||
| cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= | ||||
| cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM= | ||||
| cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= | ||||
| cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= | ||||
| cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= | ||||
| cloud.google.com/go/firestore v1.6.0 h1:dMIWvm+3O0E3DM7kcZPH0FBQ94Xg/OMkdTNDaY9itbI= | ||||
| cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU= | ||||
| cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= | ||||
| cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= | ||||
| cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw= | ||||
| cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= | ||||
| cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= | ||||
| cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= | ||||
| cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= | ||||
| cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= | ||||
|  | @ -47,63 +56,55 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo | |||
| cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= | ||||
| cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= | ||||
| cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= | ||||
| cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY= | ||||
| cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM= | ||||
| cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14= | ||||
| cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= | ||||
| dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= | ||||
| firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= | ||||
| firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= | ||||
| github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= | ||||
| github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= | ||||
| github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= | ||||
| github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= | ||||
| github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= | ||||
| github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU= | ||||
| github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= | ||||
| github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= | ||||
| github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= | ||||
| github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= | ||||
| github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= | ||||
| github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= | ||||
| github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= | ||||
| github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk= | ||||
| github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= | ||||
| github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= | ||||
| github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= | ||||
| github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||
| github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= | ||||
| github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||
| github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= | ||||
| github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= | ||||
| github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= | ||||
| github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | ||||
| github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= | ||||
| github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= | ||||
| github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= | ||||
| github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= | ||||
| github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI= | ||||
| github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= | ||||
| github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed h1:OZmjad4L3H8ncOIR8rnb5MREYqG8ixi5+WbeUsquF0c= | ||||
| github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||
| github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||
| github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||
| github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||
| github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw= | ||||
| github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| 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/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= | ||||
| github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs= | ||||
| github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= | ||||
| github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= | ||||
| github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= | ||||
| github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= | ||||
| github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= | ||||
| github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= | ||||
| github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= | ||||
| github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= | ||||
| github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= | ||||
| github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 h1:dulLQAYQFYtG5MTplgNGHWuV2D+OBD+Z8lmDBmbLg+s= | ||||
| github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= | ||||
| github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= | ||||
| github.com/envoyproxy/go-control-plane v0.10.0 h1:WVt4HEPbdRbRD/PKKPbPnIVavO6gk/h673jWyIJ016k= | ||||
| github.com/envoyproxy/go-control-plane v0.10.0/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= | ||||
| github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= | ||||
| github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | ||||
| github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE= | ||||
| github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= | ||||
| github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= | ||||
| github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= | ||||
| github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= | ||||
| github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= | ||||
| github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= | ||||
|  | @ -111,7 +112,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 | |||
| github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= | ||||
| github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||
| github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||
| github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= | ||||
| github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||
| github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= | ||||
| github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||
|  | @ -156,8 +156,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ | |||
| github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= | ||||
| github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= | ||||
| github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= | ||||
| github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= | ||||
| github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= | ||||
| github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= | ||||
|  | @ -179,53 +180,52 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe | |||
| github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= | ||||
| github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= | ||||
| github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= | ||||
| github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= | ||||
| github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= | ||||
| github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= | ||||
| github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= | ||||
| github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU= | ||||
| github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= | ||||
| github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE= | ||||
| github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= | ||||
| github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= | ||||
| github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | ||||
| github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= | ||||
| github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= | ||||
| github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= | ||||
| github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= | ||||
| github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= | ||||
| github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= | ||||
| github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= | ||||
| github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= | ||||
| github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= | ||||
| github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= | ||||
| github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= | ||||
| github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | ||||
| github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | ||||
| github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | ||||
| github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | ||||
| github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= | ||||
| github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
| github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= | ||||
| github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
| github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= | ||||
| github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
| github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk= | ||||
| github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0= | ||||
| github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= | ||||
| github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | ||||
| github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= | ||||
| github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | ||||
| github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||
| github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | ||||
| github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||
| github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= | ||||
| github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= | ||||
| github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= | ||||
| github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= | ||||
| github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||||
| github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= | ||||
| github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= | ||||
| github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= | ||||
| github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= | ||||
| github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= | ||||
| github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I= | ||||
| github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= | ||||
| github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||||
| github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||||
| github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||||
|  | @ -243,8 +243,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe | |||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= | ||||
| golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||
| 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= | ||||
|  | @ -281,7 +281,6 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | |||
| golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= | ||||
| golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
|  | @ -316,10 +315,11 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v | |||
| golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||||
| golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= | ||||
| golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= | ||||
| golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk= | ||||
| golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c= | ||||
| golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||
| golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= | ||||
| golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||
| golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
|  | @ -336,8 +336,10 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ | |||
| golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||
| golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||
| golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||
| golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM= | ||||
| golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||
| golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||
| golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= | ||||
| golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM= | ||||
| golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= | ||||
| golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
|  | @ -348,6 +350,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ | |||
| golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= | ||||
| golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/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= | ||||
|  | @ -389,17 +392,25 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w | |||
| golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 h1:2B5p2L5IfGiD7+b9BOoRMC6DgObAVZV+Fsp050NqXik= | ||||
| golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/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-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-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= | ||||
| golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||
| golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
|  | @ -407,15 +418,14 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | |||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= | ||||
| golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= | ||||
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||
| golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= | ||||
| golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs= | ||||
| golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= | ||||
|  | @ -501,10 +511,17 @@ google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6 | |||
| google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= | ||||
| google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= | ||||
| google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= | ||||
| google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E= | ||||
| google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= | ||||
| google.golang.org/api v0.60.0 h1:eq/zs5WPH4J9undYM9IP1O7dSr7Yh8Y0GtSCpzGzIUk= | ||||
| google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4= | ||||
| google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= | ||||
| google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= | ||||
| google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM= | ||||
| google.golang.org/api v0.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.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80= | ||||
| google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= | ||||
| google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= | ||||
| google.golang.org/api v0.73.0 h1:O9bThUh35K1rvUrQwTUQ1eqLC/IYyzUpWavYIO2EXvo= | ||||
| google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI= | ||||
| google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||
| google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||
| google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||
|  | @ -568,15 +585,27 @@ google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEc | |||
| google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= | ||||
| google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= | ||||
| google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= | ||||
| google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= | ||||
| google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211021150943-2b146023228c h1:FqrtZMB5Wr+/RecOM3uPJNPfWR8Upb5hAPnt7PU6i4k= | ||||
| google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145 h1:vum3nDKdleYb+aePXKFEDT2+ghuH00EgYp9B7Q7EZZE= | ||||
| google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||
| google.golang.org/genproto v0.0.0-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-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-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||
| google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||
| google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||
| google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||
| google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||
| google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||
| google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE= | ||||
| google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= | ||||
| google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= | ||||
| google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= | ||||
| google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= | ||||
|  | @ -601,10 +630,11 @@ google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ | |||
| google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= | ||||
| google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= | ||||
| google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= | ||||
| google.golang.org/grpc v1.40.0 h1:AGJ0Ih4mHjSeibYkFGh1dD9KJ/eOtZ93I6hoHhukQ5Q= | ||||
| google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= | ||||
| google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E= | ||||
| google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= | ||||
| 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.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M= | ||||
| google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= | ||||
| google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= | ||||
| google.golang.org/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= | ||||
|  | @ -626,8 +656,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 | |||
| gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= | ||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= | ||||
| gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= | ||||
| honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= | ||||
|  |  | |||
							
								
								
									
										8
									
								
								main.go
									
										
									
									
									
								
							
							
						
						|  | @ -16,10 +16,14 @@ var ( | |||
| 
 | ||||
| func main() { | ||||
| 	cli.AppHelpTemplate += fmt.Sprintf(` | ||||
| Try 'ntfy COMMAND --help' for more information. | ||||
| Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information. | ||||
| 
 | ||||
| To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues. | ||||
| If you want to chat, simply join the Discord server (https://discord.gg/cT7ECsZj9w), or | ||||
| the Matrix room (https://matrix.to/#/#ntfy:matrix.org). | ||||
| 
 | ||||
| ntfy %s (%s), runtime %s, built at %s | ||||
| Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0 | ||||
| Copyright (C) 2022 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2 | ||||
| `, version, commit[:7], runtime.Version(), date) | ||||
| 
 | ||||
| 	app := cmd.New() | ||||
|  |  | |||
							
								
								
									
										14
									
								
								mkdocs.yml
									
										
									
									
									
								
							
							
						
						|  | @ -1,11 +1,11 @@ | |||
| site_dir: server/docs | ||||
| site_name: ntfy | ||||
| site_url: https://ntfy.sh | ||||
| site_description: simple HTTP-based pub-sub | ||||
| site_description: Send push notifications to your phone via PUT/POST | ||||
| copyright: Made with ❤️ by Philipp C. Heckel | ||||
| repo_name: binwiederhier/ntfy | ||||
| repo_url: https://github.com/binwiederhier/ntfy | ||||
| edit_uri: edit/main/docs/ | ||||
| edit_uri: blob/main/docs/ | ||||
| 
 | ||||
| theme: | ||||
|   name: material | ||||
|  | @ -17,23 +17,20 @@ theme: | |||
|   palette: | ||||
|     - media: "(prefers-color-scheme: light)"  # Light mode | ||||
|       scheme: default | ||||
|       primary: teal | ||||
|       toggle: | ||||
|         icon: material/lightbulb-outline | ||||
|         name: Switch to light mode | ||||
|         name: Switch to dark mode | ||||
|     - media: "(prefers-color-scheme: dark)"  # Dark mode | ||||
|       scheme: slate | ||||
|       primary: teal | ||||
|       accent: indigo | ||||
|       toggle: | ||||
|         icon: material/lightbulb | ||||
|         name: Switch to dark mode | ||||
|         name: Switch to light mode | ||||
|   features: | ||||
|     - search.suggest | ||||
|     - search.highlight | ||||
|     - search.share | ||||
|     - navigation.sections | ||||
|     # - navigation.instant | ||||
|     - toc.integrate | ||||
|     - content.tabs.link | ||||
| extra: | ||||
|  | @ -77,6 +74,7 @@ nav: | |||
| - "Subscribing": | ||||
|   - "From your phone": subscribe/phone.md | ||||
|   - "From the Web UI": subscribe/web.md | ||||
|   - "From the CLI": subscribe/cli.md | ||||
|   - "Using the API": subscribe/api.md | ||||
| - "Self-hosting": | ||||
|   - "Installation": install.md | ||||
|  | @ -84,6 +82,8 @@ nav: | |||
| - "Other things": | ||||
|   - "FAQs": faq.md | ||||
|   - "Examples": examples.md | ||||
|   - "Release notes": releases.md | ||||
|   - "Deprecation notices": deprecations.md | ||||
|   - "Emojis 🥳 🎉": emojis.md | ||||
|   - "Development": develop.md | ||||
|   - "Privacy policy": privacy.md | ||||
|  |  | |||
							
								
								
									
										9
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,9 @@ | |||
| # The documentation uses 'mkdocs', which is written in Python | ||||
| 
 | ||||
| # See https://github.com/squidfunk/mkdocs-material/issues/2030 | ||||
| jinja2>=2.11.1 | ||||
| 
 | ||||
| # mkdocs | ||||
| mkdocs | ||||
| mkdocs-material | ||||
| mkdocs-minify-plugin | ||||
|  | @ -18,7 +18,7 @@ fi | |||
| if [[ "$1" == *.js ]]; then | ||||
|   echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size | ||||
| // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json | ||||
| const rawEmojis = " > "$1" | ||||
| export const rawEmojis = " > "$1" | ||||
|     cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1" | ||||
| elif [[ "$1" == *.md ]]; then | ||||
|   echo "# Emoji reference | ||||
|  |  | |||
|  | @ -4,9 +4,24 @@ set -e | |||
| # Restart systemd service if it was already running. Note that "deb-systemd-invoke try-restart" will | ||||
| # only act if the service is already running. If it's not running, it's a no-op. | ||||
| # | ||||
| # TODO: This is only tested on Debian. | ||||
| # | ||||
| if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then | ||||
| if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then | ||||
|   if [ -d /run/systemd/system ]; then | ||||
|     # Create ntfy user/group | ||||
|     id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy | ||||
|     chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy | ||||
|     chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy | ||||
| 
 | ||||
|     # Hack to change permissions on cache file | ||||
|     configfile="/etc/ntfy/server.yml" | ||||
|     if [ -f "$configfile" ]; then | ||||
|       cachefile="$(cat "$configfile" | perl -n -e'/^\s*cache-file: ["'"'"']?([^"'"'"']+)["'"'"']?/ && print $1')" # Oh my, see #47 | ||||
|       if [ -n "$cachefile" ]; then | ||||
|         chown ntfy.ntfy "$cachefile" || true | ||||
|         chmod 600 "$cachefile" || true | ||||
|       fi | ||||
|     fi | ||||
| 
 | ||||
|     # Restart services | ||||
|     systemctl --system daemon-reload >/dev/null || true | ||||
|     if systemctl is-active -q ntfy.service; then | ||||
|       echo "Restarting ntfy.service ..." | ||||
|  | @ -16,4 +31,13 @@ if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then | |||
|         systemctl restart ntfy.service >/dev/null || true | ||||
|       fi | ||||
|     fi | ||||
|     if systemctl is-active -q ntfy-client.service; then | ||||
|       echo "Restarting ntfy-client.service ..." | ||||
|       if [ -x /usr/bin/deb-systemd-invoke ]; then | ||||
|         deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true | ||||
|       else | ||||
|         systemctl restart ntfy-client.service >/dev/null || true | ||||
|       fi | ||||
|     fi | ||||
|   fi | ||||
| fi | ||||
|  |  | |||
|  | @ -2,7 +2,9 @@ | |||
| set -e | ||||
| 
 | ||||
| # Delete the config if package is purged | ||||
| if [ "$1" = "purge" ]; then | ||||
|   echo "Deleting /etc/ntfy ..." | ||||
|   rm -rf /etc/ntfy || true | ||||
| if [ "$1" = "purge" ] || [ "$1" = "0" ]; then | ||||
|   id ntfy >/dev/null 2>&1 && userdel ntfy | ||||
|   rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml | ||||
|   rmdir /etc/ntfy || true | ||||
| fi | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										11
									
								
								scripts/preinst.sh
									
										
									
									
									
										Executable file
									
								
							
							
						
						|  | @ -0,0 +1,11 @@ | |||
| #!/bin/sh | ||||
| set -e | ||||
| 
 | ||||
| if [ "$1" = "install" ] || [ "$1" = "upgrade" ] || [ "$1" -ge 1 ]; then | ||||
|   # Migration of old to new config file name | ||||
|   oldconfigfile="/etc/ntfy/config.yml" | ||||
|   configfile="/etc/ntfy/server.yml" | ||||
|   if [ -f "$oldconfigfile" ] && [ ! -f "$configfile" ]; then | ||||
|     mv "$oldconfigfile" "$configfile" || true | ||||
|   fi | ||||
| fi | ||||
|  | @ -2,7 +2,8 @@ | |||
| set -e | ||||
| 
 | ||||
| # Stop systemd service | ||||
| if [ -d /run/systemd/system ] && [ "$1" = remove ]; then | ||||
| if [ -d /run/systemd/system ]; then | ||||
|   if [ "$1" = "remove" ] || [ "$1" = "0" ]; then | ||||
|     echo "Stopping ntfy.service ..." | ||||
|     if [ -x /usr/bin/deb-systemd-invoke ]; then | ||||
|       deb-systemd-invoke stop 'ntfy.service' >/dev/null || true | ||||
|  | @ -10,3 +11,4 @@ if [ -d /run/systemd/system ] && [ "$1" = remove ]; then | |||
|       systemctl stop ntfy >/dev/null 2>&1 || true | ||||
|     fi | ||||
|   fi | ||||
| fi | ||||
|  |  | |||
|  | @ -1,14 +0,0 @@ | |||
| package server | ||||
| 
 | ||||
| import ( | ||||
| 	_ "github.com/mattn/go-sqlite3" // SQLite driver | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| type cache interface { | ||||
| 	AddMessage(m *message) error | ||||
| 	Messages(topic string, since sinceTime) ([]*message, error) | ||||
| 	MessageCount(topic string) (int, error) | ||||
| 	Topics() (map[string]*topic, error) | ||||
| 	Prune(keep time.Duration) error | ||||
| } | ||||
|  | @ -1,80 +0,0 @@ | |||
| package server | ||||
| 
 | ||||
| import ( | ||||
| 	_ "github.com/mattn/go-sqlite3" // SQLite driver | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| type memCache struct { | ||||
| 	messages map[string][]*message | ||||
| 	mu       sync.Mutex | ||||
| } | ||||
| 
 | ||||
| var _ cache = (*memCache)(nil) | ||||
| 
 | ||||
| func newMemCache() *memCache { | ||||
| 	return &memCache{ | ||||
| 		messages: make(map[string][]*message), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *memCache) AddMessage(m *message) error { | ||||
| 	s.mu.Lock() | ||||
| 	defer s.mu.Unlock() | ||||
| 	if _, ok := s.messages[m.Topic]; !ok { | ||||
| 		s.messages[m.Topic] = make([]*message, 0) | ||||
| 	} | ||||
| 	s.messages[m.Topic] = append(s.messages[m.Topic], m) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *memCache) Messages(topic string, since sinceTime) ([]*message, error) { | ||||
| 	s.mu.Lock() | ||||
| 	defer s.mu.Unlock() | ||||
| 	if _, ok := s.messages[topic]; !ok { | ||||
| 		return make([]*message, 0), nil | ||||
| 	} | ||||
| 	messages := make([]*message, 0) // copy! | ||||
| 	for _, m := range s.messages[topic] { | ||||
| 		msgTime := time.Unix(m.Time, 0) | ||||
| 		if msgTime == since.Time() || msgTime.After(since.Time()) { | ||||
| 			messages = append(messages, m) | ||||
| 		} | ||||
| 	} | ||||
| 	return messages, nil | ||||
| } | ||||
| 
 | ||||
| func (s *memCache) MessageCount(topic string) (int, error) { | ||||
| 	s.mu.Lock() | ||||
| 	defer s.mu.Unlock() | ||||
| 	if _, ok := s.messages[topic]; !ok { | ||||
| 		return 0, nil | ||||
| 	} | ||||
| 	return len(s.messages[topic]), nil | ||||
| } | ||||
| 
 | ||||
| func (s *memCache) Topics() (map[string]*topic, error) { | ||||
| 	// Hack since we know when this is called there are no messages! | ||||
| 	return make(map[string]*topic), nil | ||||
| } | ||||
| 
 | ||||
| func (s *memCache) Prune(keep time.Duration) error { | ||||
| 	s.mu.Lock() | ||||
| 	defer s.mu.Unlock() | ||||
| 	for topic, _ := range s.messages { | ||||
| 		s.pruneTopic(topic, keep) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *memCache) pruneTopic(topic string, keep time.Duration) { | ||||
| 	for i, m := range s.messages[topic] { | ||||
| 		msgTime := time.Unix(m.Time, 0) | ||||
| 		if time.Since(msgTime) < keep { | ||||
| 			s.messages[topic] = s.messages[topic][i:] | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 	s.messages[topic] = make([]*message, 0) // all messages expired | ||||
| } | ||||
|  | @ -1,225 +0,0 @@ | |||
| package server | ||||
| 
 | ||||
| import ( | ||||
| 	"database/sql" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	_ "github.com/mattn/go-sqlite3" // SQLite driver | ||||
| 	"log" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| // Messages cache | ||||
| const ( | ||||
| 	createMessagesTableQuery = ` | ||||
| 		BEGIN; | ||||
| 		CREATE TABLE IF NOT EXISTS messages ( | ||||
| 			id VARCHAR(20) PRIMARY KEY, | ||||
| 			time INT NOT NULL, | ||||
| 			topic VARCHAR(64) NOT NULL, | ||||
| 			message VARCHAR(512) NOT NULL, | ||||
| 			title VARCHAR(256) NOT NULL, | ||||
| 			priority INT NOT NULL, | ||||
| 			tags VARCHAR(256) NOT NULL | ||||
| 		); | ||||
| 		CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); | ||||
| 		COMMIT; | ||||
| 	` | ||||
| 	insertMessageQuery           = `INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)` | ||||
| 	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ?` | ||||
| 	selectMessagesSinceTimeQuery = ` | ||||
| 		SELECT id, time, message, title, priority, tags | ||||
| 		FROM messages  | ||||
| 		WHERE topic = ? AND time >= ? | ||||
| 		ORDER BY time ASC | ||||
| 	` | ||||
| 	selectMessagesCountQuery        = `SELECT COUNT(*) FROM messages` | ||||
| 	selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` | ||||
| 	selectTopicsQuery               = `SELECT topic, MAX(time) FROM messages GROUP BY topic` | ||||
| ) | ||||
| 
 | ||||
| // Schema management queries | ||||
| const ( | ||||
| 	currentSchemaVersion          = 1 | ||||
| 	createSchemaVersionTableQuery = ` | ||||
| 		CREATE TABLE IF NOT EXISTS schemaVersion ( | ||||
| 			id INT PRIMARY KEY, | ||||
| 			version INT NOT NULL | ||||
| 		); | ||||
| 	` | ||||
| 	insertSchemaVersion      = `INSERT INTO schemaVersion VALUES (1, ?)` | ||||
| 	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` | ||||
| 
 | ||||
| 	// 0 -> 1 | ||||
| 	migrate0To1AlterMessagesTableQuery = ` | ||||
| 		BEGIN; | ||||
| 		ALTER TABLE messages ADD COLUMN title VARCHAR(256) NOT NULL DEFAULT(''); | ||||
| 		ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0); | ||||
| 		ALTER TABLE messages ADD COLUMN tags VARCHAR(256) NOT NULL DEFAULT(''); | ||||
| 		COMMIT; | ||||
| 	` | ||||
| ) | ||||
| 
 | ||||
| type sqliteCache struct { | ||||
| 	db *sql.DB | ||||
| } | ||||
| 
 | ||||
| var _ cache = (*sqliteCache)(nil) | ||||
| 
 | ||||
| func newSqliteCache(filename string) (*sqliteCache, error) { | ||||
| 	db, err := sql.Open("sqlite3", filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if err := setupDB(db); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &sqliteCache{ | ||||
| 		db: db, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (c *sqliteCache) AddMessage(m *message) error { | ||||
| 	_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ",")) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error) { | ||||
| 	rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix()) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	messages := make([]*message, 0) | ||||
| 	for rows.Next() { | ||||
| 		var timestamp int64 | ||||
| 		var priority int | ||||
| 		var id, msg, title, tagsStr string | ||||
| 		if err := rows.Scan(&id, ×tamp, &msg, &title, &priority, &tagsStr); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		if msg == "" { | ||||
| 			msg = " " // Hack: never return empty messages; this should not happen | ||||
| 		} | ||||
| 		var tags []string | ||||
| 		if tagsStr != "" { | ||||
| 			tags = strings.Split(tagsStr, ",") | ||||
| 		} | ||||
| 		messages = append(messages, &message{ | ||||
| 			ID:       id, | ||||
| 			Time:     timestamp, | ||||
| 			Event:    messageEvent, | ||||
| 			Topic:    topic, | ||||
| 			Message:  msg, | ||||
| 			Title:    title, | ||||
| 			Priority: priority, | ||||
| 			Tags:     tags, | ||||
| 		}) | ||||
| 	} | ||||
| 	if err := rows.Err(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return messages, nil | ||||
| } | ||||
| 
 | ||||
| func (c *sqliteCache) MessageCount(topic string) (int, error) { | ||||
| 	rows, err := c.db.Query(selectMessageCountForTopicQuery, topic) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	var count int | ||||
| 	if !rows.Next() { | ||||
| 		return 0, errors.New("no rows found") | ||||
| 	} | ||||
| 	if err := rows.Scan(&count); err != nil { | ||||
| 		return 0, err | ||||
| 	} else if err := rows.Err(); err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	return count, nil | ||||
| } | ||||
| 
 | ||||
| func (s *sqliteCache) Topics() (map[string]*topic, error) { | ||||
| 	rows, err := s.db.Query(selectTopicsQuery) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	topics := make(map[string]*topic, 0) | ||||
| 	for rows.Next() { | ||||
| 		var id string | ||||
| 		var last int64 | ||||
| 		if err := rows.Scan(&id, &last); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		topics[id] = newTopic(id, time.Unix(last, 0)) | ||||
| 	} | ||||
| 	if err := rows.Err(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return topics, nil | ||||
| } | ||||
| 
 | ||||
| func (s *sqliteCache) Prune(keep time.Duration) error { | ||||
| 	_, err := s.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix()) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func setupDB(db *sql.DB) error { | ||||
| 	// If 'messages' table does not exist, this must be a new database | ||||
| 	rowsMC, err := db.Query(selectMessagesCountQuery) | ||||
| 	if err != nil { | ||||
| 		return setupNewDB(db) | ||||
| 	} | ||||
| 	defer rowsMC.Close() | ||||
| 
 | ||||
| 	// If 'messages' table exists, check 'schemaVersion' table | ||||
| 	schemaVersion := 0 | ||||
| 	rowsSV, err := db.Query(selectSchemaVersionQuery) | ||||
| 	if err == nil { | ||||
| 		defer rowsSV.Close() | ||||
| 		if !rowsSV.Next() { | ||||
| 			return errors.New("cannot determine schema version: cache file may be corrupt") | ||||
| 		} | ||||
| 		if err := rowsSV.Scan(&schemaVersion); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Do migrations | ||||
| 	if schemaVersion == currentSchemaVersion { | ||||
| 		return nil | ||||
| 	} else if schemaVersion == 0 { | ||||
| 		return migrateFrom0To1(db) | ||||
| 	} | ||||
| 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion) | ||||
| } | ||||
| 
 | ||||
| func setupNewDB(db *sql.DB) error { | ||||
| 	if _, err := db.Exec(createMessagesTableQuery); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := db.Exec(createSchemaVersionTableQuery); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func migrateFrom0To1(db *sql.DB) error { | ||||
| 	log.Print("Migrating cache database schema: from 0 to 1") | ||||
| 	if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := db.Exec(createSchemaVersionTableQuery); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := db.Exec(insertSchemaVersion, 1); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||