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/ | dist/ | ||||||
|  | build/ | ||||||
| .idea/ | .idea/ | ||||||
| site/ |  | ||||||
| server/docs/ | server/docs/ | ||||||
|  | server/site/ | ||||||
|  | tools/fbsend/fbsend | ||||||
|  | playground/ | ||||||
| *.iml | *.iml | ||||||
|  | node_modules/ | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| before: | before: | ||||||
|   hooks: |   hooks: | ||||||
|     - go mod download |     - go mod download | ||||||
|  |     - go mod tidy | ||||||
| builds: | builds: | ||||||
|   - |   - | ||||||
|     id: ntfy |     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}}" |       - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" | ||||||
|     goos: [linux] |     goos: [linux] | ||||||
|     goarch: [amd64] |     goarch: [amd64] | ||||||
|  |     hooks: | ||||||
|  |       post: | ||||||
|  |         - upx "{{ .Path }}" # apt install upx | ||||||
|   - |   - | ||||||
|     id: ntfy_armv7 |     id: ntfy_armv7 | ||||||
|     binary: ntfy |     binary: ntfy | ||||||
|  | @ -24,6 +28,9 @@ builds: | ||||||
|     goos: [linux] |     goos: [linux] | ||||||
|     goarch: [arm] |     goarch: [arm] | ||||||
|     goarm: [7] |     goarm: [7] | ||||||
|  |     hooks: | ||||||
|  |       post: | ||||||
|  |         - upx "{{ .Path }}" # apt install upx | ||||||
|   - |   - | ||||||
|     id: ntfy_arm64 |     id: ntfy_arm64 | ||||||
|     binary: ntfy |     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}}" |       - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" | ||||||
|     goos: [linux] |     goos: [linux] | ||||||
|     goarch: [arm64] |     goarch: [arm64] | ||||||
|  |     hooks: | ||||||
|  |       post: | ||||||
|  |         - upx "{{ .Path }}" # apt install upx | ||||||
| nfpms: | nfpms: | ||||||
|   - |   - | ||||||
|     package_name: ntfy |     package_name: ntfy | ||||||
|  | @ -47,12 +57,26 @@ nfpms: | ||||||
|       - rpm |       - rpm | ||||||
|     bindir: /usr/bin |     bindir: /usr/bin | ||||||
|     contents: |     contents: | ||||||
|       - src: config/config.yml |       - src: server/server.yml | ||||||
|         dst: /etc/ntfy/config.yml |         dst: /etc/ntfy/server.yml | ||||||
|         type: config |         type: "config|noreplace" | ||||||
|       - src: config/ntfy.service |       - src: server/ntfy.service | ||||||
|         dst: /lib/systemd/system/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: |     scripts: | ||||||
|  |       preinstall: "scripts/preinst.sh" | ||||||
|       postinstall: "scripts/postinst.sh" |       postinstall: "scripts/postinst.sh" | ||||||
|       preremove: "scripts/prerm.sh" |       preremove: "scripts/prerm.sh" | ||||||
|       postremove: "scripts/postrm.sh" |       postremove: "scripts/postrm.sh" | ||||||
|  | @ -62,8 +86,10 @@ archives: | ||||||
|     files: |     files: | ||||||
|       - LICENSE |       - LICENSE | ||||||
|       - README.md |       - README.md | ||||||
|       - config/config.yml |       - server/server.yml | ||||||
|       - config/ntfy.service |       - server/ntfy.service | ||||||
|  |       - client/client.yml | ||||||
|  |       - client/ntfy-client.service | ||||||
|     replacements: |     replacements: | ||||||
|       386: i386 |       386: i386 | ||||||
|       amd64: x86_64 |       amd64: x86_64 | ||||||
|  | @ -89,6 +115,7 @@ dockers: | ||||||
|       - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" |       - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" | ||||||
|     use: buildx |     use: buildx | ||||||
|     dockerfile: Dockerfile |     dockerfile: Dockerfile | ||||||
|  |     goarch: arm64 | ||||||
|     build_flag_templates: |     build_flag_templates: | ||||||
|       - "--platform=linux/arm64/v8" |       - "--platform=linux/arm64/v8" | ||||||
|   - image_templates: |   - image_templates: | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								LICENSE
									
										
									
									
									
								
							
							
						
						|  | @ -186,7 +186,7 @@ | ||||||
|       same "printed page" as the copyright notice for easier |       same "printed page" as the copyright notice for easier | ||||||
|       identification within third-party archives. |       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"); |    Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|    you may not use this file except in compliance with 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 | 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. | 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.> |     ntfy | ||||||
|     Copyright (C) <year>  <name of author> |     Copyright (C) 2021 Philipp C. Heckel | ||||||
| 
 | 
 | ||||||
|     This program is free software; you can redistribute it and/or modify |     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 |     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) | VERSION := $(shell git describe --tag) | ||||||
| 
 | 
 | ||||||
| .PHONY: | .PHONY: | ||||||
|  | @ -38,25 +37,54 @@ help: | ||||||
| 	@echo "  make install-lint                - Install golint" | 	@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
 | # Test/check targets
 | ||||||
| 
 | 
 | ||||||
| check: test fmt-check vet lint staticcheck | check: test fmt-check vet lint staticcheck | ||||||
| 
 | 
 | ||||||
| test: .PHONY | test: .PHONY | ||||||
| 	$(GO) test ./... | 	go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') | ||||||
| 
 | 
 | ||||||
| race: .PHONY | race: .PHONY | ||||||
| 	$(GO) test -race ./... | 	go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') | ||||||
| 
 | 
 | ||||||
| coverage: | coverage: | ||||||
| 	mkdir -p build/coverage | 	mkdir -p build/coverage | ||||||
| 	$(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... | 	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 | 	go tool cover -func build/coverage/coverage.txt | ||||||
| 
 | 
 | ||||||
| coverage-html: | coverage-html: | ||||||
| 	mkdir -p build/coverage | 	mkdir -p build/coverage | ||||||
| 	$(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... | 	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 | 	go tool cover -html build/coverage/coverage.txt | ||||||
| 
 | 
 | ||||||
| coverage-upload: | coverage-upload: | ||||||
| 	cd build/coverage && (curl -s https://codecov.io/bash | bash) | 	cd build/coverage && (curl -s https://codecov.io/bash | bash) | ||||||
|  | @ -65,33 +93,30 @@ coverage-upload: | ||||||
| # Lint/formatting targets
 | # Lint/formatting targets
 | ||||||
| 
 | 
 | ||||||
| fmt: | fmt: | ||||||
| 	$(GO) fmt ./... | 	gofmt -s -w . | ||||||
| 
 | 
 | ||||||
| fmt-check: | fmt-check: | ||||||
| 	test -z $(shell gofmt -l .) | 	test -z $(shell gofmt -l .) | ||||||
| 
 | 
 | ||||||
| vet: | vet: | ||||||
| 	$(GO) vet ./... | 	go vet ./... | ||||||
| 
 | 
 | ||||||
| lint: | lint: | ||||||
| 	which golint || $(GO) get -u golang.org/x/lint/golint | 	which golint || go install golang.org/x/lint/golint@latest | ||||||
| 	$(GO) list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status | 	go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status | ||||||
| 
 | 
 | ||||||
| staticcheck: .PHONY | staticcheck: .PHONY | ||||||
| 	rm -rf build/staticcheck | 	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 | 	mkdir -p build/staticcheck | ||||||
| 	ln -s "$(GO)" build/staticcheck/go | 	ln -s "go" build/staticcheck/go | ||||||
| 	PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... | 	PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... | ||||||
| 	rm -rf build/staticcheck | 	rm -rf build/staticcheck | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # Building targets
 | # Building targets
 | ||||||
| 
 | 
 | ||||||
| docs: .PHONY | build-deps: docs web | ||||||
| 	mkdocs build |  | ||||||
| 
 |  | ||||||
| build-deps: docs |  | ||||||
| 	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 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; } | 	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 | 	goreleaser build --snapshot --rm-dist --debug | ||||||
| 
 | 
 | ||||||
| build-simple: clean | 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 | 	export CGO_ENABLED=1 | ||||||
| 	$(GO) build \
 | 	go build \
 | ||||||
| 		-o dist/ntfy_linux_amd64/ntfy \
 | 		-o dist/ntfy_linux_amd64/ntfy \
 | ||||||
| 		-tags sqlite_omit_load_extension,osusergo,netgo \
 | 		-tags sqlite_omit_load_extension,osusergo,netgo \
 | ||||||
| 		-ldflags \
 | 		-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)" | 		"-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 | clean: .PHONY | ||||||
| 	rm -rf dist build | 	rm -rf dist build server/docs server/site | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # Releasing targets
 | # 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 | 	goreleaser release --rm-dist --debug | ||||||
| 
 | 
 | ||||||
| release-snapshot: build-deps | release-snapshot: build-deps | ||||||
|  | @ -132,4 +174,4 @@ install: | ||||||
| install-deb: | install-deb: | ||||||
| 	sudo systemctl stop ntfy || true | 	sudo systemctl stop ntfy || true | ||||||
| 	sudo apt-get purge ntfy || true | 	sudo apt-get purge ntfy || true | ||||||
| 	sudo dpkg -i dist/*.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://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. | **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**. | 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. | too. | ||||||
| 
 | 
 | ||||||
| <p> | <p> | ||||||
|   <img src="server/static/img/screenshot-curl.png" height="180"> |   <img src="web/public/static/img/screenshot-curl.png" height="180"> | ||||||
|   <img src="server/static/img/screenshot-web-detail.png" height="180"> |   <img src="web/public/static/img/screenshot-web-detail.png" height="180"> | ||||||
|   <img src="server/static/img/screenshot-phone-main.jpg" height="180"> |   <img src="web/public/static/img/screenshot-phone-main.jpg" height="180"> | ||||||
|   <img src="server/static/img/screenshot-phone-detail.jpg" height="180"> |   <img src="web/public/static/img/screenshot-phone-detail.jpg" height="180"> | ||||||
|   <img src="server/static/img/screenshot-phone-notification.jpg" height="180"> |   <img src="web/public/static/img/screenshot-phone-notification.jpg" height="180"> | ||||||
| </p> | </p> | ||||||
| 
 | 
 | ||||||
| ## Usage | ## **[Documentation](https://ntfy.sh/docs/)** | ||||||
| 
 | 
 | ||||||
| ### Publishing messages | [Getting started](https://ntfy.sh/docs/) | | ||||||
| 
 | [Android/iOS](https://ntfy.sh/docs/subscribe/phone/) | | ||||||
| Publishing messages can be done via PUT or POST using. Topics are created on the fly by subscribing or publishing to them. | [API](https://ntfy.sh/docs/publish/) | | ||||||
| Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. | [Install / Self-hosting](https://ntfy.sh/docs/install/) | | ||||||
| 
 | [Building](https://ntfy.sh/docs/develop/) | ||||||
| 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`. |  | ||||||
| 
 | 
 | ||||||
| ## Contributing | ## Contributing | ||||||
| I welcome any and all contributions. Just create a PR or an issue. | I welcome any and all contributions. Just create a PR or an issue. | ||||||
| 
 | 
 | ||||||
| ## Contact me | ## 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), | You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)  | ||||||
| or find more contact information [on my website](https://heckel.io/about). | (bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information | ||||||
|  | [on my website](https://heckel.io/about). | ||||||
| 
 | 
 | ||||||
| ## License | ## License | ||||||
| Made with ❤️ by [Philipp C. Heckel](https://heckel.io).    | Made with ❤️ by [Philipp C. Heckel](https://heckel.io).    | ||||||
|  | @ -208,11 +47,21 @@ The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GP | ||||||
| 
 | 
 | ||||||
| Third party libraries and resources: | Third party libraries and resources: | ||||||
| * [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI | * [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 | * [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds | ||||||
| * [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI | * [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 | * [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 | * [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 | * [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) | * [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) | * [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 | package cmd | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"errors" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/urfave/cli/v2" | 	"github.com/urfave/cli/v2" | ||||||
| 	"github.com/urfave/cli/v2/altsrc" | 	"github.com/urfave/cli/v2/altsrc" | ||||||
| 	"heckel.io/ntfy/config" |  | ||||||
| 	"heckel.io/ntfy/server" |  | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
| 	"log" |  | ||||||
| 	"os" | 	"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 | // New creates a new CLI application | ||||||
| func New() *cli.App { | 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{ | 	return &cli.App{ | ||||||
| 		Name:                   "ntfy", | 		Name:                   "ntfy", | ||||||
| 		Usage:                  "Simple pub-sub notification service", | 		Usage:                  "Simple pub-sub notification service", | ||||||
| 		UsageText:              "ntfy [OPTION..]", | 		UsageText:              "ntfy [OPTION..]", | ||||||
| 		HideHelp:               true, |  | ||||||
| 		HideVersion:            true, | 		HideVersion:            true, | ||||||
| 		EnableBashCompletion:   true, |  | ||||||
| 		UseShortOptionHandling: true, | 		UseShortOptionHandling: true, | ||||||
| 		Reader:                 os.Stdin, | 		Reader:                 os.Stdin, | ||||||
| 		Writer:                 os.Stdout, | 		Writer:                 os.Stdout, | ||||||
| 		ErrWriter:              os.Stderr, | 		ErrWriter:              os.Stderr, | ||||||
| 		Action:                 execRun, | 		Commands: []*cli.Command{ | ||||||
| 		Before:                 initConfigFileInputSource("config", flags), | 			// Server commands | ||||||
| 		Flags:                  flags, | 			cmdServe, | ||||||
| 	} | 			cmdUser, | ||||||
| } | 			cmdAccess, | ||||||
| 
 | 
 | ||||||
| func execRun(c *cli.Context) error { | 			// Client commands | ||||||
| 	// Read all the options | 			cmdPublish, | ||||||
| 	listenHTTP := c.String("listen-http") | 			cmdSubscribe, | ||||||
| 	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") |  | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	// 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 | // 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 |  | ||||||
							
								
								
									
										798
									
								
								docs/config.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,21 +1,70 @@ | ||||||
| # Configuring the ntfy server | # Configuring the ntfy server | ||||||
| The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/config.yml`,  | The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,  | ||||||
| see [config.yml](https://github.com/binwiederhier/ntfy/blob/main/config/config.yml)), via command line arguments  | see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments  | ||||||
| or using environment variables. | or using environment variables. | ||||||
| 
 | 
 | ||||||
| ## Quick start | ## 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: | If everything works as it should, you'll see something like this: | ||||||
| ``` | ``` | ||||||
| $ ntfy                 | $ ntfy serve | ||||||
| 2021/11/30 19:59:08 Listening on :80 | 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), | 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 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). | 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 | ## Message cache | ||||||
| If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period | 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 | 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). | * `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**. |   **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 | You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only | ||||||
| [`since=` parameter](subscribe/api.md#fetching-cached-messages). | 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.) | ## Behind a proxy (TLS, etc.) | ||||||
| 
 |  | ||||||
| !!! warning | !!! warning | ||||||
|     If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise all visitors are rate limited |     If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are | ||||||
|     as if they are one. |     [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`  | It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates  | ||||||
| flag. This will instruct the [rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary  | using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.  | ||||||
| identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will | 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. | 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  | === "/etc/ntfy/server.yml" | ||||||
| are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself. |     ``` 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) | ## Firebase (FCM) | ||||||
| !!! info | !!! 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/) | 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`) | 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) | 4. Build your own Android .apk following [these instructions](develop.md#android-app) | ||||||
| 
 | 
 | ||||||
| Example: | Example: | ||||||
|  | @ -75,80 +565,260 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" | ||||||
| ## Rate limiting | ## Rate limiting | ||||||
| !!! info | !!! info | ||||||
|     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.  |     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.  | ||||||
|     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. | 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. | * `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  | ### Request limits | ||||||
| options that start with the word `visitor` apply only on a per-visitor basis.    |  | ||||||
| 
 |  | ||||||
| In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests. | 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)): | 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`).  | 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 | 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-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. | ||||||
|  |   | ||||||
|  | ### Attachment limits | ||||||
|  | Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant  | ||||||
|  | per-visitor limits: | ||||||
| 
 | 
 | ||||||
| During normal usage, you shouldn't encounter this limit at all, and even if you burst a few requests shortly (e.g. when you  | * `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M. | ||||||
| reconnect after a connection drop), it shouldn't have any effect. |   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 | ## 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 | CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment | ||||||
| variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | ||||||
| 
 | 
 | ||||||
| | Config option | Env variable | Format | Default | Description | | | Config option                              | Env variable                                    | Format                                              | Default      | Description                                                                                                                                                                                                                     | | ||||||
| |---|---|---|---|---| | |--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ||||||
| | `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | | | `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -            | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  | | ||||||
| | `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-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`        | Listen address for the HTTP web server                                                                                                                                                                                          | | ||||||
| | `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | | | `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`.                                                                                                                               | | ||||||
| | `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | | | `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -            | Path to a Unix socket to listen on                                                                                                                                                                                              | | ||||||
| | `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). | | | `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -            | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||||
| | `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). | | | `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -            | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||||
| | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. | | | `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).                        | | ||||||
| | `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. | | | `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).             | | ||||||
| | `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | | | `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.                                        | | ||||||
| | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. | | | `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).                                                                                           | | ||||||
| | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | | `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`.                                                                                                                             | | ||||||
| | `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 | | | `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.                                                                                                 | | ||||||
| | `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 | | | `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -            | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  | | ||||||
| | `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-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 *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 | ## Command line options | ||||||
| ``` | ``` | ||||||
| $ ntfy --help | $ ntfy serve --help | ||||||
| NAME: | NAME: | ||||||
|    ntfy - Simple pub-sub notification service |    ntfy serve - Run the ntfy server | ||||||
| 
 | 
 | ||||||
| USAGE: | USAGE: | ||||||
|    ntfy [OPTION..] |    ntfy serve [OPTIONS..] | ||||||
| 
 | 
 | ||||||
| GLOBAL OPTIONS: | CATEGORY: | ||||||
|    --config value, -c value                           config file (default: /etc/ntfy/config.yml) [$NTFY_CONFIG_FILE] |    Server commands | ||||||
|    --listen-http value, -l value                      ip:port used to as listen address (default: ":80") [$NTFY_LISTEN_HTTP] |  | ||||||
|    --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] |  | ||||||
|    --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] |  | ||||||
|    --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. | 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 | ||||||
| 
 | 
 | ||||||
| ntfy v1.4.8 (7b8185c), runtime go1.17, built at 1637872539 | OPTIONS: | ||||||
| Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0 |    --config value, -c value                          config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] | ||||||
|  |    --base-url value, -B value                        externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] | ||||||
|  |    --listen-http value, -l value                     ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] | ||||||
|  |    --listen-https value, -L value                    ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] | ||||||
|  |    --listen-unix value, -U value                     listen on unix socket path [$NTFY_LISTEN_UNIX] | ||||||
|  |    --key-file value, -K value                        private key file, if listen-https is set [$NTFY_KEY_FILE] | ||||||
|  |    --cert-file value, -E value                       certificate file, if listen-https is set [$NTFY_CERT_FILE] | ||||||
|  |    --firebase-key-file value, -F value               Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] | ||||||
|  |    --cache-file value, -C value                      cache file used for message caching [$NTFY_CACHE_FILE] | ||||||
|  |    --cache-duration since, -b since                  buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] | ||||||
|  |    --auth-file value, -H value                       auth database file used for access control [$NTFY_AUTH_FILE] | ||||||
|  |    --auth-default-access value, -p value             default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] | ||||||
|  |    --attachment-cache-dir value                      cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] | ||||||
|  |    --attachment-total-size-limit value, -A value     limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] | ||||||
|  |    --attachment-file-size-limit value, -Y value      per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] | ||||||
|  |    --attachment-expiry-duration value, -X value      duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] | ||||||
|  |    --keepalive-interval value, -k value              interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] | ||||||
|  |    --manager-interval value, -m value                interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] | ||||||
|  |    --web-root value                                  sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT] | ||||||
|  |    --smtp-sender-addr value                          SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] | ||||||
|  |    --smtp-sender-user value                          SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] | ||||||
|  |    --smtp-sender-pass value                          SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] | ||||||
|  |    --smtp-sender-from value                          SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] | ||||||
|  |    --smtp-server-listen value                        SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] | ||||||
|  |    --smtp-server-domain value                        SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] | ||||||
|  |    --smtp-server-addr-prefix value                   SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] | ||||||
|  |    --global-topic-limit value, -T value              total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] | ||||||
|  |    --visitor-subscription-limit value                number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] | ||||||
|  |    --visitor-attachment-total-size-limit value       total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] | ||||||
|  |    --visitor-attachment-daily-bandwidth-limit value  total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] | ||||||
|  |    --visitor-request-limit-burst value               initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] | ||||||
|  |    --visitor-request-limit-replenish value           interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] | ||||||
|  |    --visitor-request-limit-exempt-hosts value        hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] | ||||||
|  |    --visitor-email-limit-burst value                 initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] | ||||||
|  |    --visitor-email-limit-replenish value             interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] | ||||||
|  |    --behind-proxy, -P                                if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] | ||||||
|  |    --help, -h                                        show help (default: false) | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										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 |   || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | ## Low disk space alerts | ||||||
|  | Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but  | ||||||
|  | effective.  | ||||||
|  | 
 | ||||||
|  | ``` bash  | ||||||
|  | #!/bin/bash | ||||||
|  | 
 | ||||||
|  | mingigs=10 | ||||||
|  | avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }') | ||||||
|  | topicurl=https://ntfy.sh/mytopic | ||||||
|  | 
 | ||||||
|  | if [ -n "$avail" ]; then | ||||||
|  |   curl \ | ||||||
|  |     -d "Only $avail GB available on the root disk. Better clean that up." \ | ||||||
|  |     -H "Title: Low disk space alert on $(hostname)" \ | ||||||
|  |     -H "Priority: high" \ | ||||||
|  |     -H "Tags: warning,cd" \ | ||||||
|  |     $topicurl | ||||||
|  | fi | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ## Server-sent messages in your web app | ## Server-sent messages in your web app | ||||||
| Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own | Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own | ||||||
| web application. Check out the <a href="/example.html">live example</a> or just look the source of this page. | web application. Check out the <a href="/example.html">live example</a> or just look the source of this page. | ||||||
|  | @ -64,5 +85,48 @@ It looked something like this: | ||||||
|     curl -d "$(hostname),$count,$time" ntfy.sh/results |     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? | ## 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>. | 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. | 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. | client network disruptions. | ||||||
| 
 | 
 | ||||||
| ## Can I self-host it? | ## 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). | of the app and [self-host your own ntfy server](install.md). | ||||||
| 
 | 
 | ||||||
| ## How much battery does the Android app use? | ## How much battery does the Android app use? | ||||||
| If you use the ntfy.sh server and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,  | If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,  | ||||||
| the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,  | the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,  | ||||||
| or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 4% of | or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of | ||||||
| battery in 17h of use (on my phone). I use it, and it makes no difference to me. | battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty  | ||||||
|  | decent now. | ||||||
| 
 | 
 | ||||||
| ## What is instant delivery? | ## What is instant delivery? | ||||||
| [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the | [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the | ||||||
|  |  | ||||||
|  | @ -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. | That's it. After you tap "Subscribe", the app is listening for new messages on that topic. | ||||||
| 
 | 
 | ||||||
| ## Step 2: Send a message | ## 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 | 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, | ||||||
| is in the request body. Here's an example showing how to publish a simple message using a POST request: | 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)" | === "Command line (curl)" | ||||||
|     ``` |     ``` | ||||||
|     curl -d "Backup successful 😀" ntfy.sh/mytopic |     curl -d "Backup successful 😀" ntfy.sh/mytopic | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
|  | === "ntfy CLI" | ||||||
|  |     ``` | ||||||
|  |     ntfy publish mytopic "Backup successful 😀" | ||||||
|  |     ``` | ||||||
|  | 
 | ||||||
| === "HTTP" | === "HTTP" | ||||||
|     ``` http |     ``` http | ||||||
|     POST /mytopic HTTP/1.1 |     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 😀")) |        strings.NewReader("Backup successful 😀")) | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
|  | === "Python" | ||||||
|  |     ``` python | ||||||
|  |     requests.post("https://ntfy.sh/mytopic", | ||||||
|  |         data="Backup successful 😀".encode(encoding='utf-8')) | ||||||
|  |     ``` | ||||||
|  | 
 | ||||||
| === "PHP" | === "PHP" | ||||||
|     ``` php-inline |     ``` php-inline | ||||||
|     file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ |     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: | This will create a notification that looks like this: | ||||||
| 
 | 
 | ||||||
| <figure markdown> | <figure markdown> | ||||||
|   { width=500 } |   { width=500 } | ||||||
|   <figcaption>Android notification</figcaption> |   <figcaption>Android notification</figcaption> | ||||||
| </figure> | </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: | Here's another video showing the entire process: | ||||||
| 
 | 
 | ||||||
| <figure> | <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> |   <figcaption>Sending push notifications to your Android phone</figcaption> | ||||||
| </figure> | </figure> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,18 +1,24 @@ | ||||||
| # Install your own ntfy server | # Installing ntfy | ||||||
| **Self-hosting your own ntfy server** is pretty straight forward. Just install the binary, package or Docker image, then  | 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.  | configure it and run it. Just like any other software. No fuzz.  | ||||||
| 
 | 
 | ||||||
| !!! info | !!! info | ||||||
|     The following steps are only required if you want to **self-host your own ntfy server**. If you just want to  |     The following steps are only required if you want to **self-host your own ntfy server or you want to use the ntfy CLI**. | ||||||
|     [send messages using ntfy.sh](publish.md), you don't need to install anything. |     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 | ## General steps | ||||||
| The ntfy server comes as a statically linked binary and is shipped as tarball, deb/rpm packages and as a Docker image. | 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. | We support amd64, armv7 and arm64. | ||||||
| 
 | 
 | ||||||
| 1. Install ntfy using one of the methods described below | 1. Install ntfy using one of the methods described below | ||||||
| 2. Then (optionally) edit `/etc/ntfy/config.yml` (see [configuration](config.md)) | 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. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm). | 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 | ## Binaries and packages | ||||||
| Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and | Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and | ||||||
|  | @ -20,23 +26,29 @@ deb/rpm packages. | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_x86_64.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_x86_64.tar.gz | ||||||
|     sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy |     tar zxvf ntfy_1.18.0_linux_x86_64.tar.gz | ||||||
|     sudo ./ntfy |     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" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.tar.gz | ||||||
|     sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy |     tar zxvf ntfy_1.18.0_linux_armv7.tar.gz | ||||||
|     sudo ./ntfy |     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" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.tar.gz | ||||||
|     sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy |     tar zxvf ntfy_1.18.0_linux_arm64.tar.gz | ||||||
|     sudo ./ntfy |     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 | ## Debian/Ubuntu repository | ||||||
|  | @ -82,7 +94,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.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 dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -90,7 +102,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```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 dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -98,7 +110,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```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 dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -108,36 +120,50 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```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 enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.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 enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.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 enable ntfy  | ||||||
|     sudo systemctl start 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 | ## Docker | ||||||
| The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty | The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty | ||||||
| straight forward to use. | straight forward to use. | ||||||
| 
 | 
 | ||||||
| The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent  | 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`, | [message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,  | ||||||
| so you can edit `/etc/ntfy/config.yml`. | you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`. | ||||||
| 
 | 
 | ||||||
| Basic usage (no cache or additional config): | 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): | With persistent cache (configured as command line arguments): | ||||||
|  | @ -147,18 +173,28 @@ docker run \ | ||||||
|   -p 80:80 \ |   -p 80:80 \ | ||||||
|   -it \ |   -it \ | ||||||
|   binwiederhier/ntfy \ |   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 | ```bash | ||||||
| docker run \ | docker run \ | ||||||
|   -v /etc/ntfy:/etc/ntfy \ |   -v /etc/ntfy:/etc/ntfy \ | ||||||
|   -p 80:80 \ |   -p 80:80 \ | ||||||
|   -it \ |   -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 | ## Go | ||||||
| To install via Go, simply run: | To install via Go, simply run: | ||||||
| ```bash | ```bash | ||||||
|  |  | ||||||
|  | @ -38,7 +38,7 @@ Here's an example showing how to publish a simple message using a POST request: | ||||||
| 
 | 
 | ||||||
| === "PowerShell" | === "PowerShell" | ||||||
|     ``` powershell |     ``` powershell | ||||||
|     Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup Successful 😀" -UseBasicParsing |     Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "Python" | === "Python" | ||||||
|  | @ -126,7 +126,7 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an | ||||||
|     ``` powershell |     ``` powershell | ||||||
|     $uri = "https://ntfy.sh/phil_alerts" |     $uri = "https://ntfy.sh/phil_alerts" | ||||||
|     $headers = @{ Title="Unauthorized access detected" |     $headers = @{ Title="Unauthorized access detected" | ||||||
|                   Priority="Urgent" |                   Priority="urgent" | ||||||
|                   Tags="warning,skull" } |                   Tags="warning,skull" } | ||||||
|     $body = "Remote access to phils-laptop detected. Act right away."               |     $body = "Remote access to phils-laptop detected. Act right away."               | ||||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing |     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing | ||||||
|  | @ -245,13 +245,13 @@ notification sounds and vibration patterns on your phone to map to these priorit | ||||||
| 
 | 
 | ||||||
| The following priorities exist: | The following priorities exist: | ||||||
| 
 | 
 | ||||||
| | Priority | Icon | ID | Name | Description | | | Priority             | Icon                                       | ID  | Name           | Description                                                                                            | | ||||||
| |---|---|---|---|---| | |----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------| | ||||||
| | Max priority |  | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. | | | Max priority         |  | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification.                 | | ||||||
| | High priority |  | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. | | | High priority        |  | `4` | `high`         | Long vibration burst, default notification sound with a pop-over notification.                         | | ||||||
| | **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. | | | **Default priority** | *(none)*                                   | `3` | `default`      | Short default vibration and sound. Default notification behavior.                                      | | ||||||
| | Low priority |  |`2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. | | | Low priority         |  | `2` | `low`          | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. | | ||||||
| | Min priority |  | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". | | | Min priority         |  | `1` | `min`          | No vibration or sound. The notification will be under the fold in "Other notifications".               | | ||||||
| 
 | 
 | ||||||
| You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`). | You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`). | ||||||
| 
 | 
 | ||||||
|  | @ -297,7 +297,7 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P | ||||||
| === "PowerShell" | === "PowerShell" | ||||||
|     ``` powershell |     ``` powershell | ||||||
|     $uri = "https://ntfy.sh/phil_alerts" |     $uri = "https://ntfy.sh/phil_alerts" | ||||||
|     $headers = @{ Priority="Urgent" } |     $headers = @{ Priority="5" } | ||||||
|     $body = "An urgent message" |     $body = "An urgent message" | ||||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing |     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing | ||||||
|     ``` |     ``` | ||||||
|  | @ -1117,13 +1117,13 @@ that, your IP address appears in the e-mail body. This is to prevent abuse. | ||||||
|     ``` powershell |     ``` powershell | ||||||
|     $uri = "https://ntfy.sh/alerts" |     $uri = "https://ntfy.sh/alerts" | ||||||
|     $headers = @{ Title"="Low disk space alert" |     $headers = @{ Title"="Low disk space alert" | ||||||
|                   Priority=4 |                   Priority="high" | ||||||
|                   Tags="warning,skull,backup-host,ssh-login") |                   Tags="warning,skull,backup-host,ssh-login") | ||||||
|                   Email="phil@example.com" } |                   Email="phil@example.com" } | ||||||
|     $body = "Unknown login from 5.31.23.83 to backups.example.com" |     $body = "Unknown login from 5.31.23.83 to backups.example.com" | ||||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing |     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing | ||||||
|     ``` |     ``` | ||||||
|      | 
 | ||||||
| === "Python" | === "Python" | ||||||
|     ``` python |     ``` python | ||||||
|     requests.post("https://ntfy.sh/alerts", |     requests.post("https://ntfy.sh/alerts", | ||||||
|  | @ -1237,8 +1237,7 @@ Here's a simple example: | ||||||
| === "PowerShell" | === "PowerShell" | ||||||
|     ``` powershell |     ``` powershell | ||||||
|     $uri = "https://ntfy.example.com/mysecrets" |     $uri = "https://ntfy.example.com/mysecrets" | ||||||
|     $basicAuthValue = "Basic [user:pass-bese64encoded]" |     $headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" } | ||||||
|     $headers = @{ Authorization=$basicAuthValue } |  | ||||||
|     $body = "Look ma, with auth" |     $body = "Look ma, with auth" | ||||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing |     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing | ||||||
|     ``` |     ``` | ||||||
|  | @ -1405,7 +1404,7 @@ to `no`. This will instruct the server not to forward messages to Firebase. | ||||||
|     $body = "This message won't be forwarded to FCM" |     $body = "This message won't be forwarded to FCM" | ||||||
|     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing |     Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing | ||||||
|     ``` |     ``` | ||||||
|      | 
 | ||||||
| === "Python" | === "Python" | ||||||
|     ``` python |     ``` python | ||||||
|     requests.post("https://ntfy.sh/mytopic", |     requests.post("https://ntfy.sh/mytopic", | ||||||
|  |  | ||||||
							
								
								
									
										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) { | .md-header__button.md-logo :is(img, svg) { | ||||||
|     width: unset !important; |     width: unset !important; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .md-typeset h4 { | ||||||
|  |     font-weight: 500 !important; | ||||||
|  |     margin: 0 !important; | ||||||
|  |     font-size: 1.1em !important; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .admonition { | ||||||
|  |     font-size: .74rem !important; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| article { | article { | ||||||
|     padding-bottom: 50px; |     padding-bottom: 50px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| figure iframe, figure img, figure video { | figure img, figure video { | ||||||
|     filter: drop-shadow(3px 3px 3px #ccc); |  | ||||||
|     border-radius: 7px; |     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 { | figure video { | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     max-height: 450px; |     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 | # 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  | 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), | ||||||
| app or script by subscribing the API. This page describes how to subscribe via API. You may also want to check out the  | or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to  | ||||||
| page that describes how to [publish messages](../publish.md). | 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  | 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: | 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) |   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 | * [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  | 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  | 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. | [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" | ||||||
|     ``` http |     ``` http | ||||||
|     GET /disk-alerts/json HTTP/1.1 |     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" | ||||||
|     ``` php-inline |     ``` php-inline | ||||||
|     $fp = fopen('https://ntfy.sh/disk-alerts/json', 'r'); |     $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); |     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 | 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  | 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). | 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 | 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),  | 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  | [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" | ||||||
|     ``` php-inline |     ``` php-inline | ||||||
|     $fp = fopen('https://ntfy.sh/disk-alerts/raw', 'r'); |     $fp = fopen('https://ntfy.sh/disk-alerts/raw', 'r'); | ||||||
|  | @ -161,85 +188,54 @@ format. Keepalive messages are sent as empty lines. | ||||||
|     fclose($fp); |     fclose($fp); | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| ## JSON message format | ## WebSockets | ||||||
| Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON | You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely  | ||||||
| format of the message. It's very straight forward: | 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.   | ||||||
| 
 | 
 | ||||||
| | Field | Required | Type | Example | Description | | The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the  | ||||||
| |---|---|---|---|---| | [JSON stream endpoint](#subscribe-as-json-stream).  | ||||||
| | `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` | |  | ||||||
| | `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 | |  | ||||||
| 
 | 
 | ||||||
| Here's an example for each message type: | === "Command line (websocat)" | ||||||
| 
 |     ``` | ||||||
| === "Notification message" |     $ websocat wss://ntfy.sh/mytopic/ws | ||||||
|     ``` json |     {"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"} | ||||||
|     { |     {"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"} | ||||||
|       "id": "wze9zgqK41", |  | ||||||
|       "time": 1638542110, |  | ||||||
|       "event": "message", |  | ||||||
|       "topic": "phil_alerts", |  | ||||||
|       "priority": 5, |  | ||||||
|       "tags": [ |  | ||||||
|         "warning", |  | ||||||
|         "skull" |  | ||||||
|       ], |  | ||||||
|       "title": "Unauthorized access detected", |  | ||||||
|       "message": "Remote access to phils-laptop detected. Act right away." |  | ||||||
|     } |  | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
|  | === "HTTP" | ||||||
|  |     ``` http | ||||||
|  |     GET /disk-alerts/ws HTTP/1.1 | ||||||
|  |     Host: ntfy.sh | ||||||
|  |     Upgrade: websocket | ||||||
|  |     Connection: Upgrade | ||||||
| 
 | 
 | ||||||
| === "Notification message (minimal)" |     HTTP/1.1 101 Switching Protocols | ||||||
|     ``` json |     Upgrade: websocket | ||||||
|     { |     Connection: Upgrade | ||||||
|       "id": "wze9zgqK41", |     ... | ||||||
|       "time": 1638542110, |  | ||||||
|       "event": "message", |  | ||||||
|       "topic": "phil_alerts", |  | ||||||
|       "message": "Remote access to phils-laptop detected. Act right away." |  | ||||||
|     } |  | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "Open message" | === "Go" | ||||||
|     ``` json |     ``` go | ||||||
|     { |     import "github.com/gorilla/websocket" | ||||||
|       "id": "2pgIAaGrQ8", | 	ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil) | ||||||
|       "time": 1638542215, | 	messageType, data, err := ws.ReadMessage() | ||||||
|       "event": "open", |     ... | ||||||
|       "topic": "phil_alerts" |  | ||||||
|     } |  | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "Keepalive message"  | === "JavaScript" | ||||||
|     ``` json |     ``` javascript | ||||||
|     { |     const socket = new WebSocket('wss://ntfy.sh/mytopic/ws'); | ||||||
|       "id": "371sevb0pD", |     socket.addEventListener('message', function (event) { | ||||||
|       "time": 1638542275, |         console.log(event.data); | ||||||
|       "event": "keepalive", |     }); | ||||||
|       "topic": "phil_alerts" |     ``` | ||||||
|     } |  | ||||||
|     ```     |  | ||||||
| 
 | 
 | ||||||
| ## Advanced features | ## Advanced features | ||||||
| 
 | 
 | ||||||
| ### Fetching cached messages | ### Poll for 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" |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ### Polling |  | ||||||
| You can also just poll for messages if you don't like the long-standing connection using the `poll=1` | 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 | query parameter. The connection will end after all available messages have been read. This parameter can be | ||||||
| combined with `since=` (defaults to `since=all`). | combined with `since=` (defaults to `since=all`). | ||||||
|  | @ -248,9 +244,52 @@ combined with `since=` (defaults to `since=all`). | ||||||
| curl -s "ntfy.sh/mytopic/json?poll=1" | curl -s "ntfy.sh/mytopic/json?poll=1" | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| ### Subscribing to multiple topics | ### Fetch cached messages | ||||||
| It's possible to subscribe to multiple topics in one HTTP call by providing a | Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network | ||||||
| comma-separated list of topics in the URL. This allows you to reduce the number of connections you have to maintain: | 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 | $ curl -s ntfy.sh/mytopic1,mytopic2/json | ||||||
|  | @ -258,3 +297,126 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json | ||||||
| {"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"} | {"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"} | ||||||
| {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} | {"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`       | ✔️       | *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": "sPs71M8A2T", | ||||||
|  |         "time": 1643935928, | ||||||
|  |         "event": "message", | ||||||
|  |         "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": "Movement detected in the yard. You better go check" | ||||||
|  |     } | ||||||
|  |     ``` | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | === "Notification message (minimal)" | ||||||
|  |     ``` json | ||||||
|  |     { | ||||||
|  |         "id": "wze9zgqK41", | ||||||
|  |         "time": 1638542110, | ||||||
|  |         "event": "message", | ||||||
|  |         "topic": "phil_alerts", | ||||||
|  |         "message": "Remote access to phils-laptop detected. Act right away." | ||||||
|  |     } | ||||||
|  |     ``` | ||||||
|  | 
 | ||||||
|  | === "Open message" | ||||||
|  |     ``` json | ||||||
|  |     { | ||||||
|  |         "id": "2pgIAaGrQ8", | ||||||
|  |         "time": 1638542215, | ||||||
|  |         "event": "open", | ||||||
|  |         "topic": "phil_alerts" | ||||||
|  |     } | ||||||
|  |     ``` | ||||||
|  | 
 | ||||||
|  | === "Keepalive message" | ||||||
|  |     ``` json | ||||||
|  |     { | ||||||
|  |         "id": "371sevb0pD", | ||||||
|  |         "time": 1638542275, | ||||||
|  |         "event": "keepalive", | ||||||
|  |         "topic": "phil_alerts" | ||||||
|  |     } | ||||||
|  |     ```     | ||||||
|  | 
 | ||||||
|  | === "Poll request message" | ||||||
|  |     ``` json | ||||||
|  |     { | ||||||
|  |         "id": "371sevb0pD", | ||||||
|  |         "time": 1638542275, | ||||||
|  |         "event": "poll_request", | ||||||
|  |         "topic": "phil_alerts" | ||||||
|  |     } | ||||||
|  |     ``` | ||||||
|  | 
 | ||||||
|  | ## 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. | ||||||
|  | 
 | ||||||
|  | | 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). | 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). | 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://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a> | ||||||
| <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a> | <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a> | ||||||
| 
 | 
 | ||||||
|  | @ -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 | from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that | ||||||
| the F-Droid flavor does not use Firebase. | the F-Droid flavor does not use Firebase. | ||||||
| 
 | 
 | ||||||
| ### Overview | ## Overview | ||||||
| A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty | A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty | ||||||
| straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them. | 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"> | <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-main.png"><img src="../../static/img/android-screenshot-main.png"/></a> | ||||||
|     <a href="../../static/img/android-screenshot-detail.jpg"><img src="../../static/img/android-screenshot-detail.jpg"/></a> |     <a href="../../static/img/android-screenshot-detail.png"><img src="../../static/img/android-screenshot-detail.png"/></a> | ||||||
|     <a href="../../static/img/android-screenshot-add.jpg"><img src="../../static/img/android-screenshot-add.jpg"/></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-instant.jpg"><img src="../../static/img/android-screenshot-add-instant.jpg"/></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-other.jpg"><img src="../../static/img/android-screenshot-add-other.jpg"/></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> | </div> | ||||||
| 
 | 
 | ||||||
| If those screenshots are still not enough, here's a video: | If those screenshots are still not enough, here's a video: | ||||||
| 
 | 
 | ||||||
| <figure> | <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> |   <figcaption>Sending push notifications to your Android phone</figcaption> | ||||||
| </figure> | </figure> | ||||||
| 
 | 
 | ||||||
| ### Message priority | ## Message priority | ||||||
| When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines | When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines | ||||||
| how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate. | 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> |   <figcaption>Per-priority sound/vibration settings</figcaption> | ||||||
| </figure> | </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.  | Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.  | ||||||
| when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which  | when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which  | ||||||
| you'll see as a permanent notification that looks like this: | you'll see as a permanent notification that looks like this: | ||||||
|  | @ -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> |   <figcaption>Turning off the persistent instant delivery notification</figcaption> | ||||||
| </figure> | </figure> | ||||||
| 
 | 
 | ||||||
| ### Limitations without instant delivery | **Limitations without instant delivery**: Without instant delivery, **messages may arrive with a significant delay**  | ||||||
| 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  | (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. | 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  | 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. | 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. | 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 | ## iPhone/iOS | ||||||
| I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app | I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app | ||||||
| for ntfy, but it's in the works. You can track the status on GitHub. | 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). | To learn how to send messages, check out the [publishing page](../publish.md). | ||||||
| 
 | 
 | ||||||
| <div id="web-screenshots" class="screenshots"> | <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-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> | </div> | ||||||
| 
 | 
 | ||||||
| To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend, | 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 ( | require ( | ||||||
| 	cloud.google.com/go/firestore v1.6.1 // indirect | 	cloud.google.com/go/firestore v1.6.1 // indirect | ||||||
| 	cloud.google.com/go/storage v1.18.2 // indirect | 	cloud.google.com/go/storage v1.21.0 // indirect | ||||||
| 	firebase.google.com/go v3.13.0+incompatible | 	firebase.google.com/go v3.13.0+incompatible | ||||||
| 	github.com/BurntSushi/toml v0.4.1 // indirect | 	github.com/BurntSushi/toml v1.0.0 // indirect | ||||||
| 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect | 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect | ||||||
| 	github.com/mattn/go-sqlite3 v1.14.9 | 	github.com/emersion/go-smtp v0.15.0 | ||||||
| 	github.com/urfave/cli/v2 v2.3.0 | 	github.com/gabriel-vasile/mimetype v1.4.0 | ||||||
| 	golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 // indirect | 	github.com/gorilla/websocket v1.5.0 | ||||||
| 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac | 	github.com/mattn/go-sqlite3 v1.14.12 | ||||||
| 	google.golang.org/api v0.60.0 | 	github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 | ||||||
| 	gopkg.in/yaml.v2 v2.4.0 // indirect | 	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 ( | require ( | ||||||
| 	cloud.google.com/go v0.97.0 // indirect | 	cloud.google.com/go v0.100.2 // indirect | ||||||
| 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect | 	cloud.google.com/go/compute v1.5.0 // indirect | ||||||
| 	github.com/cespare/xxhash v1.1.0 // indirect | 	cloud.google.com/go/iam v0.3.0 // indirect | ||||||
| 	github.com/cespare/xxhash/v2 v2.1.2 // indirect | 	github.com/AlekSi/pointer v1.0.0 // indirect | ||||||
| 	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect | 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||||
| 	github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect | 	github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect | ||||||
| 	github.com/envoyproxy/go-control-plane v0.10.0 // indirect |  | ||||||
| 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect |  | ||||||
| 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect | 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect | ||||||
| 	github.com/golang/protobuf v1.5.2 // indirect | 	github.com/golang/protobuf v1.5.2 // indirect | ||||||
| 	github.com/google/go-cmp v0.5.6 // indirect | 	github.com/google/go-cmp v0.5.7 // indirect | ||||||
| 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect | 	github.com/googleapis/gax-go/v2 v2.2.0 // indirect | ||||||
|  | 	github.com/pkg/errors v0.9.1 // indirect | ||||||
|  | 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||||
| 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||||||
| 	go.opencensus.io v0.23.0 // indirect | 	go.opencensus.io v0.23.0 // indirect | ||||||
| 	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect | 	golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect | ||||||
| 	golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 // indirect | 	golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect | ||||||
| 	golang.org/x/text v0.3.7 // indirect | 	golang.org/x/text v0.3.7 // indirect | ||||||
| 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect | 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect | ||||||
| 	google.golang.org/appengine v1.6.7 // indirect | 	google.golang.org/appengine v1.6.7 // indirect | ||||||
| 	google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145 // indirect | 	google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect | ||||||
| 	google.golang.org/grpc v1.41.0 // indirect | 	google.golang.org/grpc v1.45.0 // indirect | ||||||
| 	google.golang.org/protobuf v1.27.1 // 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.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= | ||||||
| cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= | 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.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.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.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= | ||||||
| cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= | ||||||
| cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= | ||||||
| cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= | 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.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= | ||||||
| cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= | ||||||
|  | cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= | ||||||
|  | cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= | ||||||
|  | cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= | ||||||
|  | cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM= | ||||||
|  | cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= | ||||||
| cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= | ||||||
| cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= | ||||||
| cloud.google.com/go/firestore v1.6.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 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= | ||||||
| cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= | cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= | ||||||
|  | cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw= | ||||||
|  | cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= | ||||||
|  | cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= | ||||||
| cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= | ||||||
| cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= | ||||||
| cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= | ||||||
|  | @ -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.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= | ||||||
| cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= | ||||||
| cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= | ||||||
| cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY= | cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14= | ||||||
| cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM= | cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= | ||||||
| dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= | ||||||
| firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= | firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= | ||||||
| firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= | firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= | ||||||
|  | github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= | ||||||
|  | github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= | ||||||
| github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= | 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 v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU= | ||||||
| github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= | 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/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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= | ||||||
| github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= | github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= | ||||||
| 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.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 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.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/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/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/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/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-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-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-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/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-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-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||||
| github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||||
| github.com/cncf/xds/go v0.0.0-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/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= | ||||||
| github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= |  | ||||||
| github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= | github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= | ||||||
| github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= | github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= | ||||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
|  | 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.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.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.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.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.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.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.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.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.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | ||||||
| github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE= | github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= | ||||||
| github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= | 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/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 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= | 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/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-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-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-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 h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= | ||||||
| github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | 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.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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
| github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
| github.com/google/go-cmp v0.5.6 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.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 h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= | ||||||
| github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= | ||||||
| github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= | ||||||
|  | @ -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-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= | ||||||
| github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= | ||||||
| github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= | ||||||
| github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= |  | ||||||
| github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
| github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= | ||||||
| github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= | ||||||
| github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= | github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= | ||||||
| github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU= |  | ||||||
| github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= | github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= | ||||||
|  | github.com/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/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= | ||||||
| github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= | ||||||
| github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= | ||||||
| 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-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/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.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/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/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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= | ||||||
| github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | 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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | ||||||
| github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | ||||||
| github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | ||||||
| github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= | github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= | ||||||
| github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||||
| github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= | github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk= | ||||||
| github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0= | ||||||
| github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||||
| 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/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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | ||||||
| github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= | github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= | ||||||
| github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | ||||||
| github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= |  | ||||||
| github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | ||||||
| github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||||
| github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= |  | ||||||
| github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= |  | ||||||
| github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= | ||||||
| github.com/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/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.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.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.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/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||||
| github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= | github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I= | ||||||
| github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= | github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= | ||||||
| github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||||||
| github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||||||
| github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||||||
|  | @ -243,8 +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-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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
| golang.org/x/crypto v0.0.0-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
|  | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= | ||||||
| golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||||
| golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||||
| golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||||
|  | @ -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.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||||
| golang.org/x/mod v0.4.1/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.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-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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||||
| golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/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-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-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-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-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||||
| golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c= | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||||
| golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||||
|  | golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= | ||||||
|  | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||||
| golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||||
| golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||||
|  | @ -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-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||||
| golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||||
| golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||||
| golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM= | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||||
| golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= | ||||||
|  | golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM= | ||||||
|  | golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= | ||||||
| golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | @ -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-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= | ||||||
| golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
|  | @ -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-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-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-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-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-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-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-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-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-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-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/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-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.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||||
| golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||||
|  | @ -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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||||
| golang.org/x/text v0.3.4/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.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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||||
| golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= | 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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||||
| golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||||
| golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||||
| golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||||
| golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= | golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs= | ||||||
| golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
| golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
| golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= | ||||||
|  | @ -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.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= | ||||||
| google.golang.org/api v0.56.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.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.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= | ||||||
| google.golang.org/api v0.60.0 h1:eq/zs5WPH4J9undYM9IP1O7dSr7Yh8Y0GtSCpzGzIUk= | google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= | ||||||
| google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4= | 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.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||||
| google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||||
| google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||||
|  | @ -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-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-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-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-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-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-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-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
| google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145/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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= | ||||||
| google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= | ||||||
| google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= | ||||||
|  | @ -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.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.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.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.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= | ||||||
| google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E= | google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= | ||||||
| google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= | google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= | ||||||
|  | google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M= | ||||||
|  | google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= | ||||||
| google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= | ||||||
| google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= | ||||||
| google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= | ||||||
|  | @ -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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= | ||||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||||
| gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||||
|  | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||||
| gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= | ||||||
| gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= | ||||||
|  | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= | ||||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= | ||||||
| honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/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() { | func main() { | ||||||
| 	cli.AppHelpTemplate += fmt.Sprintf(` | 	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 | 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) | `, version, commit[:7], runtime.Version(), date) | ||||||
| 
 | 
 | ||||||
| 	app := cmd.New() | 	app := cmd.New() | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								mkdocs.yml
									
										
									
									
									
								
							
							
						
						|  | @ -1,11 +1,11 @@ | ||||||
| site_dir: server/docs | site_dir: server/docs | ||||||
| site_name: ntfy | site_name: ntfy | ||||||
| site_url: https://ntfy.sh | 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 | copyright: Made with ❤️ by Philipp C. Heckel | ||||||
| repo_name: binwiederhier/ntfy | repo_name: binwiederhier/ntfy | ||||||
| repo_url: https://github.com/binwiederhier/ntfy | repo_url: https://github.com/binwiederhier/ntfy | ||||||
| edit_uri: edit/main/docs/ | edit_uri: blob/main/docs/ | ||||||
| 
 | 
 | ||||||
| theme: | theme: | ||||||
|   name: material |   name: material | ||||||
|  | @ -17,23 +17,20 @@ theme: | ||||||
|   palette: |   palette: | ||||||
|     - media: "(prefers-color-scheme: light)"  # Light mode |     - media: "(prefers-color-scheme: light)"  # Light mode | ||||||
|       scheme: default |       scheme: default | ||||||
|       primary: teal |  | ||||||
|       toggle: |       toggle: | ||||||
|         icon: material/lightbulb-outline |         icon: material/lightbulb-outline | ||||||
|         name: Switch to light mode |         name: Switch to dark mode | ||||||
|     - media: "(prefers-color-scheme: dark)"  # Dark mode |     - media: "(prefers-color-scheme: dark)"  # Dark mode | ||||||
|       scheme: slate |       scheme: slate | ||||||
|       primary: teal |  | ||||||
|       accent: indigo |       accent: indigo | ||||||
|       toggle: |       toggle: | ||||||
|         icon: material/lightbulb |         icon: material/lightbulb | ||||||
|         name: Switch to dark mode |         name: Switch to light mode | ||||||
|   features: |   features: | ||||||
|     - search.suggest |     - search.suggest | ||||||
|     - search.highlight |     - search.highlight | ||||||
|     - search.share |     - search.share | ||||||
|     - navigation.sections |     - navigation.sections | ||||||
|     # - navigation.instant |  | ||||||
|     - toc.integrate |     - toc.integrate | ||||||
|     - content.tabs.link |     - content.tabs.link | ||||||
| extra: | extra: | ||||||
|  | @ -77,6 +74,7 @@ nav: | ||||||
| - "Subscribing": | - "Subscribing": | ||||||
|   - "From your phone": subscribe/phone.md |   - "From your phone": subscribe/phone.md | ||||||
|   - "From the Web UI": subscribe/web.md |   - "From the Web UI": subscribe/web.md | ||||||
|  |   - "From the CLI": subscribe/cli.md | ||||||
|   - "Using the API": subscribe/api.md |   - "Using the API": subscribe/api.md | ||||||
| - "Self-hosting": | - "Self-hosting": | ||||||
|   - "Installation": install.md |   - "Installation": install.md | ||||||
|  | @ -84,6 +82,8 @@ nav: | ||||||
| - "Other things": | - "Other things": | ||||||
|   - "FAQs": faq.md |   - "FAQs": faq.md | ||||||
|   - "Examples": examples.md |   - "Examples": examples.md | ||||||
|  |   - "Release notes": releases.md | ||||||
|  |   - "Deprecation notices": deprecations.md | ||||||
|   - "Emojis 🥳 🎉": emojis.md |   - "Emojis 🥳 🎉": emojis.md | ||||||
|   - "Development": develop.md |   - "Development": develop.md | ||||||
|   - "Privacy policy": privacy.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 | if [[ "$1" == *.js ]]; then | ||||||
|   echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size |   echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size | ||||||
| // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json | // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json | ||||||
| const rawEmojis = " > "$1" | export const rawEmojis = " > "$1" | ||||||
|     cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1" |     cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1" | ||||||
| elif [[ "$1" == *.md ]]; then | elif [[ "$1" == *.md ]]; then | ||||||
|   echo "# Emoji reference |   echo "# Emoji reference | ||||||
|  |  | ||||||
|  | @ -4,16 +4,40 @@ set -e | ||||||
| # Restart systemd service if it was already running. Note that "deb-systemd-invoke try-restart" will | # 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. | # 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" ] || [ "$1" -ge 1 ]; then | ||||||
| # |   if [ -d /run/systemd/system ]; then | ||||||
| if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then |     # Create ntfy user/group | ||||||
|   systemctl --system daemon-reload >/dev/null || true |     id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy | ||||||
|   if systemctl is-active -q ntfy.service; then |     chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy | ||||||
|     echo "Restarting ntfy.service ..." |     chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy | ||||||
|     if [ -x /usr/bin/deb-systemd-invoke ]; then | 
 | ||||||
|       deb-systemd-invoke try-restart ntfy.service >/dev/null || true |     # Hack to change permissions on cache file | ||||||
|     else |     configfile="/etc/ntfy/server.yml" | ||||||
|       systemctl restart ntfy.service >/dev/null || true |     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 ..." | ||||||
|  |       if [ -x /usr/bin/deb-systemd-invoke ]; then | ||||||
|  |         deb-systemd-invoke try-restart ntfy.service >/dev/null || true | ||||||
|  |       else | ||||||
|  |         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 |   fi | ||||||
| fi | fi | ||||||
|  |  | ||||||
|  | @ -2,7 +2,9 @@ | ||||||
| set -e | set -e | ||||||
| 
 | 
 | ||||||
| # Delete the config if package is purged | # Delete the config if package is purged | ||||||
| if [ "$1" = "purge" ]; then | if [ "$1" = "purge" ] || [ "$1" = "0" ]; then | ||||||
|   echo "Deleting /etc/ntfy ..." |   id ntfy >/dev/null 2>&1 && userdel ntfy | ||||||
|   rm -rf /etc/ntfy || true |   rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml | ||||||
|  |   rmdir /etc/ntfy || true | ||||||
| fi | 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,11 +2,13 @@ | ||||||
| set -e | set -e | ||||||
| 
 | 
 | ||||||
| # Stop systemd service | # Stop systemd service | ||||||
| if [ -d /run/systemd/system ] && [ "$1" = remove ]; then | if [ -d /run/systemd/system ]; then | ||||||
|   echo "Stopping ntfy.service ..." |   if [ "$1" = "remove" ] || [ "$1" = "0" ]; then | ||||||
|   if [ -x /usr/bin/deb-systemd-invoke ]; then |     echo "Stopping ntfy.service ..." | ||||||
|     deb-systemd-invoke stop 'ntfy.service' >/dev/null || true |     if [ -x /usr/bin/deb-systemd-invoke ]; then | ||||||
|   else |       deb-systemd-invoke stop 'ntfy.service' >/dev/null || true | ||||||
|     systemctl stop ntfy >/dev/null 2>&1 || true |     else | ||||||
|  |       systemctl stop ntfy >/dev/null 2>&1 || true | ||||||
|  |     fi | ||||||
|   fi |   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 |  | ||||||
| } |  | ||||||