Merge branch 'main' of github.com:binwiederhier/ntfy
This commit is contained in:
		
						commit
						c7b790e070
					
				
					 52 changed files with 2243 additions and 1110 deletions
				
			
		
							
								
								
									
										39
									
								
								.github/workflows/build.yaml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								.github/workflows/build.yaml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | ||||||
|  | name: build | ||||||
|  | on: [push, pull_request] | ||||||
|  | jobs: | ||||||
|  |   build: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - | ||||||
|  |         name: Install Go | ||||||
|  |         uses: actions/setup-go@v2 | ||||||
|  |         with: | ||||||
|  |           go-version: '1.18.x' | ||||||
|  |       - | ||||||
|  |         name: Install node | ||||||
|  |         uses: actions/setup-node@v2 | ||||||
|  |         with: | ||||||
|  |           node-version: '16' | ||||||
|  |       - | ||||||
|  |         name: Checkout code | ||||||
|  |         uses: actions/checkout@v2 | ||||||
|  |       - | ||||||
|  |         name: Cache Go and npm modules | ||||||
|  |         uses: actions/cache@v3 | ||||||
|  |         with: | ||||||
|  |           path: | | ||||||
|  |             ~/go/pkg/mod | ||||||
|  |             ~/go/bin | ||||||
|  |             ~/.npm | ||||||
|  |             web/node_modules | ||||||
|  |           key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }} | ||||||
|  |           restore-keys: ${{ runner.os }}-ntfy- | ||||||
|  |       - | ||||||
|  |         name: Install dependencies | ||||||
|  |         run: make build-deps-ubuntu | ||||||
|  |       - | ||||||
|  |         name: Build all the things | ||||||
|  |         run: make build | ||||||
|  |       - | ||||||
|  |         name: Print build results and checksums | ||||||
|  |         run: make cli-build-results | ||||||
							
								
								
									
										72
									
								
								.github/workflows/codeql-analysis.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										72
									
								
								.github/workflows/codeql-analysis.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -1,72 +0,0 @@ | ||||||
| # For most projects, this workflow file will not need changing; you simply need |  | ||||||
| # to commit it to your repository. |  | ||||||
| # |  | ||||||
| # You may wish to alter this file to override the set of languages analyzed, |  | ||||||
| # or to provide custom queries or build logic. |  | ||||||
| # |  | ||||||
| # ******** NOTE ******** |  | ||||||
| # We have attempted to detect the languages in your repository. Please check |  | ||||||
| # the `language` matrix defined below to confirm you have the correct set of |  | ||||||
| # supported CodeQL languages. |  | ||||||
| # |  | ||||||
| name: "CodeQL" |  | ||||||
| 
 |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     branches: [ main ] |  | ||||||
|   pull_request: |  | ||||||
|     # The branches below must be a subset of the branches above |  | ||||||
|     branches: [ main ] |  | ||||||
|   schedule: |  | ||||||
|     - cron: '21 10 * * 5' |  | ||||||
| 
 |  | ||||||
| jobs: |  | ||||||
|   analyze: |  | ||||||
|     name: Analyze |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     permissions: |  | ||||||
|       actions: read |  | ||||||
|       contents: read |  | ||||||
|       security-events: write |  | ||||||
| 
 |  | ||||||
|     strategy: |  | ||||||
|       fail-fast: false |  | ||||||
|       matrix: |  | ||||||
|         language: [ 'go', 'javascript' ] |  | ||||||
|         # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] |  | ||||||
|         # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support |  | ||||||
| 
 |  | ||||||
|     steps: |  | ||||||
|     - name: Checkout repository |  | ||||||
|       uses: actions/checkout@v3 |  | ||||||
| 
 |  | ||||||
|     # Initializes the CodeQL tools for scanning. |  | ||||||
|     - name: Initialize CodeQL |  | ||||||
|       uses: github/codeql-action/init@v2 |  | ||||||
|       with: |  | ||||||
|         languages: ${{ matrix.language }} |  | ||||||
|         # If you wish to specify custom queries, you can do so here or in a config file. |  | ||||||
|         # By default, queries listed here will override any specified in a config file. |  | ||||||
|         # Prefix the list here with "+" to use these queries and those in the config file. |  | ||||||
|          |  | ||||||
|         # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs |  | ||||||
|         # queries: security-extended,security-and-quality |  | ||||||
| 
 |  | ||||||
|          |  | ||||||
|     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). |  | ||||||
|     # If this step fails, then you should remove it and run the build manually (see below) |  | ||||||
|     - name: Autobuild |  | ||||||
|       uses: github/codeql-action/autobuild@v2 |  | ||||||
| 
 |  | ||||||
|     # ℹ️ Command-line programs to run using the OS shell. |  | ||||||
|     # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun |  | ||||||
| 
 |  | ||||||
|     #   If the Autobuild fails above, remove it and uncomment the following three lines.  |  | ||||||
|     #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. |  | ||||||
| 
 |  | ||||||
|     # - run: | |  | ||||||
|     #   echo "Run, Build Application using script" |  | ||||||
|     #   ./location_of_script_within_repo/buildscript.sh |  | ||||||
| 
 |  | ||||||
|     - name: Perform CodeQL Analysis |  | ||||||
|       uses: github/codeql-action/analyze@v2 |  | ||||||
							
								
								
									
										50
									
								
								.github/workflows/release.yaml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								.github/workflows/release.yaml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | ||||||
|  | name: release | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     tags: | ||||||
|  |       - 'v[0-9]+.[0-9]+.[0-9]+' | ||||||
|  | jobs: | ||||||
|  |   release: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - | ||||||
|  |         name: Install Go | ||||||
|  |         uses: actions/setup-go@v2 | ||||||
|  |         with: | ||||||
|  |           go-version: '1.18.x' | ||||||
|  |       - | ||||||
|  |         name: Install node | ||||||
|  |         uses: actions/setup-node@v2 | ||||||
|  |         with: | ||||||
|  |           node-version: '16' | ||||||
|  |       - | ||||||
|  |         name: Checkout code | ||||||
|  |         uses: actions/checkout@v2 | ||||||
|  |       - | ||||||
|  |         name: Cache Go and npm modules | ||||||
|  |         uses: actions/cache@v3 | ||||||
|  |         with: | ||||||
|  |           path: | | ||||||
|  |             ~/go/pkg/mod | ||||||
|  |             ~/go/bin | ||||||
|  |             ~/.npm | ||||||
|  |             web/node_modules | ||||||
|  |           key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }} | ||||||
|  |           restore-keys: ${{ runner.os }}-ntfy- | ||||||
|  |       - | ||||||
|  |         name: Docker login | ||||||
|  |         uses: docker/login-action@v2 | ||||||
|  |         with: | ||||||
|  |           username: ${{ github.repository_owner }} | ||||||
|  |           password: ${{ secrets.DOCKER_HUB_TOKEN }} | ||||||
|  |       - | ||||||
|  |         name: Install dependencies | ||||||
|  |         run: make build-deps-ubuntu | ||||||
|  |       - | ||||||
|  |         name: Build and publish | ||||||
|  |         run: make release | ||||||
|  |         env: | ||||||
|  |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |       - | ||||||
|  |         name: Print build results and checksums | ||||||
|  |         run: make cli-build-results | ||||||
							
								
								
									
										42
									
								
								.github/workflows/test.yaml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								.github/workflows/test.yaml
									
										
									
									
										vendored
									
									
								
							|  | @ -4,25 +4,45 @@ jobs: | ||||||
|   test: |   test: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps:  |     steps:  | ||||||
|       - name: Install Go |       - | ||||||
|  |         name: Install Go | ||||||
|         uses: actions/setup-go@v2 |         uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: '1.17.x' |           go-version: '1.18.x' | ||||||
|       - name: Install node |       - | ||||||
|  |         name: Install node | ||||||
|         uses: actions/setup-node@v2 |         uses: actions/setup-node@v2 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|       - name: Checkout code |       - | ||||||
|  |         name: Checkout code | ||||||
|         uses: actions/checkout@v2 |         uses: actions/checkout@v2 | ||||||
|       - name: Install dependencies |       - | ||||||
|         run: sudo apt update && sudo apt install -y python3-pip curl |         name: Cache Go and npm modules | ||||||
|       - name: Build docs (required for tests) |         uses: actions/cache@v3 | ||||||
|  |         with: | ||||||
|  |           path: | | ||||||
|  |             ~/go/pkg/mod | ||||||
|  |             ~/go/bin | ||||||
|  |             ~/.npm | ||||||
|  |             web/node_modules | ||||||
|  |           key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }} | ||||||
|  |           restore-keys: ${{ runner.os }}-ntfy- | ||||||
|  |       - | ||||||
|  |         name: Install dependencies | ||||||
|  |         run: make build-deps-ubuntu | ||||||
|  |       - | ||||||
|  |         name: Build docs (required for tests) | ||||||
|         run: make docs |         run: make docs | ||||||
|       - name: Build web app (required for tests) |       - | ||||||
|  |         name: Build web app (required for tests) | ||||||
|         run: make web |         run: make web | ||||||
|       - name: Run tests, formatting, vetting and linting |       - | ||||||
|  |         name: Run tests, formatting, vetting and linting | ||||||
|         run: make check |         run: make check | ||||||
|       - name: Run coverage |       - | ||||||
|  |         name: Run coverage | ||||||
|         run: make coverage |         run: make coverage | ||||||
|       - name: Upload coverage to codecov.io |       - | ||||||
|  |         name: Upload coverage to codecov.io | ||||||
|         run: make coverage-upload |         run: make coverage-upload | ||||||
|  |  | ||||||
|  | @ -157,6 +157,7 @@ universal_binaries: | ||||||
|   - |   - | ||||||
|     id: ntfy_darwin_all |     id: ntfy_darwin_all | ||||||
|     replace: true |     replace: true | ||||||
|  |     name_template: ntfy | ||||||
| checksum: | checksum: | ||||||
|   name_template: 'checksums.txt' |   name_template: 'checksums.txt' | ||||||
| snapshot: | snapshot: | ||||||
|  |  | ||||||
							
								
								
									
										52
									
								
								Makefile
									
										
									
									
									
								
							
							
						
						
									
										52
									
								
								Makefile
									
										
									
									
									
								
							|  | @ -79,6 +79,18 @@ build: web docs cli | ||||||
| update: web-deps-update cli-deps-update docs-deps-update | update: web-deps-update cli-deps-update docs-deps-update | ||||||
| 	docker pull alpine | 	docker pull alpine | ||||||
| 
 | 
 | ||||||
|  | # Ubuntu-specific
 | ||||||
|  | 
 | ||||||
|  | build-deps-ubuntu: | ||||||
|  | 	sudo apt update | ||||||
|  | 	sudo apt install -y \
 | ||||||
|  | 		curl \
 | ||||||
|  | 		gcc-aarch64-linux-gnu \
 | ||||||
|  | 		gcc-arm-linux-gnueabi \
 | ||||||
|  | 		upx \
 | ||||||
|  | 		jq | ||||||
|  | 	which pip3 || sudo apt install -y python3-pip | ||||||
|  | 
 | ||||||
| # Documentation
 | # Documentation
 | ||||||
| 
 | 
 | ||||||
| docs: docs-deps docs-build | docs: docs-deps docs-build | ||||||
|  | @ -114,28 +126,29 @@ web-deps: | ||||||
| web-deps-update: | web-deps-update: | ||||||
| 	cd web && npm update | 	cd web && npm update | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| # Main server/client build
 | # Main server/client build
 | ||||||
| 
 | 
 | ||||||
| cli: cli-deps | cli: cli-deps | ||||||
| 	goreleaser build --snapshot --rm-dist --debug | 	goreleaser build --snapshot --rm-dist | ||||||
| 
 | 
 | ||||||
| cli-linux-amd64: cli-deps-static-sites | cli-linux-amd64: cli-deps-static-sites | ||||||
| 	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_amd64 | 	goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64 | ||||||
| 
 | 
 | ||||||
| cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 | cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 | ||||||
| 	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_armv6 | 	goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6 | ||||||
| 
 | 
 | ||||||
| cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 | cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 | ||||||
| 	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_armv7 | 	goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7 | ||||||
| 
 | 
 | ||||||
| cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 | cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 | ||||||
| 	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_arm64 | 	goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64 | ||||||
| 
 | 
 | ||||||
| cli-windows-amd64: cli-deps-static-sites | cli-windows-amd64: cli-deps-static-sites | ||||||
| 	goreleaser build --snapshot --rm-dist --debug --id ntfy_windows_amd64 | 	goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64 | ||||||
| 
 | 
 | ||||||
| cli-darwin-all: cli-deps-static-sites | cli-darwin-all: cli-deps-static-sites | ||||||
| 	goreleaser build --snapshot --rm-dist --debug --id ntfy_darwin_all | 	goreleaser build --snapshot --rm-dist --id ntfy_darwin_all | ||||||
| 
 | 
 | ||||||
| cli-linux-server: cli-deps-static-sites | cli-linux-server: cli-deps-static-sites | ||||||
| 	# This is a target to build the CLI (including the server) manually. | 	# This is a target to build the CLI (including the server) manually. | ||||||
|  | @ -177,6 +190,7 @@ cli-deps-static-sites: | ||||||
| 
 | 
 | ||||||
| cli-deps-all: | cli-deps-all: | ||||||
| 	which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; } | 	which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; } | ||||||
|  | 	go install github.com/goreleaser/goreleaser@latest | ||||||
| 
 | 
 | ||||||
| cli-deps-gcc-armv6-armv7: | cli-deps-gcc-armv6-armv7: | ||||||
| 	which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } | 	which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } | ||||||
|  | @ -187,6 +201,18 @@ cli-deps-gcc-arm64: | ||||||
| cli-deps-update: | cli-deps-update: | ||||||
| 	go get -u | 	go get -u | ||||||
| 	go install honnef.co/go/tools/cmd/staticcheck@latest | 	go install honnef.co/go/tools/cmd/staticcheck@latest | ||||||
|  | 	go install golang.org/x/lint/golint@latest | ||||||
|  | 	go install github.com/goreleaser/goreleaser@latest | ||||||
|  | 
 | ||||||
|  | cli-build-results: | ||||||
|  | 	cat dist/config.yaml | ||||||
|  | 	[ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true | ||||||
|  | 	[ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true | ||||||
|  | 	[ -f dist/checksums.txt ] && cat dist/checksums.txt || true | ||||||
|  | 	find dist -maxdepth 2 -type f \
 | ||||||
|  | 		\( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \
 | ||||||
|  | 		-and -not -path 'dist/goreleaserdocker*' \
 | ||||||
|  | 		-exec sha256sum {} \; | ||||||
| 
 | 
 | ||||||
| # Test/check targets
 | # Test/check targets
 | ||||||
| 
 | 
 | ||||||
|  | @ -238,13 +264,13 @@ staticcheck: .PHONY | ||||||
| 
 | 
 | ||||||
| # Releasing targets
 | # Releasing targets
 | ||||||
| 
 | 
 | ||||||
| release: clean update cli-deps release-check-tags docs web check | release: clean update cli-deps release-checks docs web check | ||||||
| 	goreleaser release --rm-dist --debug | 	goreleaser release --rm-dist | ||||||
| 
 | 
 | ||||||
| release-snapshot: clean update cli-deps docs web check | release-snapshot: clean update cli-deps docs web check | ||||||
| 	goreleaser release --snapshot --skip-publish --rm-dist --debug | 	goreleaser release --snapshot --skip-publish --rm-dist | ||||||
| 
 | 
 | ||||||
| release-check-tags: | release-checks: | ||||||
| 	$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) | 	$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) | ||||||
| 	if ! grep -q $(LATEST_TAG) docs/install.md; then\
 | 	if ! grep -q $(LATEST_TAG) docs/install.md; then\
 | ||||||
| 	 	echo "ERROR: Must update docs/install.md with latest tag first.";\
 | 	 	echo "ERROR: Must update docs/install.md with latest tag first.";\
 | ||||||
|  | @ -254,6 +280,10 @@ release-check-tags: | ||||||
| 		echo "ERROR: Must update docs/releases.md with latest tag first.";\
 | 		echo "ERROR: Must update docs/releases.md with latest tag first.";\
 | ||||||
| 		exit 1;\
 | 		exit 1;\
 | ||||||
| 	fi | 	fi | ||||||
|  | 	if [ -n "$(shell git status -s)" ]; then\
 | ||||||
|  | 	  echo "ERROR: Git repository is in an unclean state.";\
 | ||||||
|  | 	  exit 1;\
 | ||||||
|  | 	fi | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # Installing targets
 | # Installing targets
 | ||||||
|  |  | ||||||
							
								
								
									
										15
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										15
									
								
								README.md
									
										
									
									
									
								
							|  | @ -33,6 +33,16 @@ too. | ||||||
| [Install / Self-hosting](https://ntfy.sh/docs/install/) | | [Install / Self-hosting](https://ntfy.sh/docs/install/) | | ||||||
| [Building](https://ntfy.sh/docs/develop/) | [Building](https://ntfy.sh/docs/develop/) | ||||||
| 
 | 
 | ||||||
|  | ## Chat | ||||||
|  | You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)  | ||||||
|  | (bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information | ||||||
|  | [on my website](https://heckel.io/about). | ||||||
|  | 
 | ||||||
|  | ## Announcements / beta testers | ||||||
|  | For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)  | ||||||
|  | topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas, | ||||||
|  | join Discord/Matrix (I'll eventually make a testing channel in Google Play). | ||||||
|  | 
 | ||||||
| ## Contributing | ## Contributing | ||||||
| I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out  | I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out  | ||||||
| the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app. | the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app. | ||||||
|  | @ -43,11 +53,6 @@ Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start im | ||||||
| <img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" /> | <img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" /> | ||||||
| </a> | </a> | ||||||
| 
 | 
 | ||||||
| ## Contact me |  | ||||||
| You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)  |  | ||||||
| (bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information |  | ||||||
| [on my website](https://heckel.io/about). |  | ||||||
| 
 |  | ||||||
| ## License | ## License | ||||||
| Made with ❤️ by [Philipp C. Heckel](https://heckel.io).    | Made with ❤️ by [Philipp C. Heckel](https://heckel.io).    | ||||||
| The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2). | The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2). | ||||||
|  |  | ||||||
|  | @ -7,9 +7,9 @@ import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
| 	"io" | 	"io" | ||||||
| 	"log" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
|  | @ -102,6 +102,7 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 	log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header) | ||||||
| 	resp, err := http.DefaultClient.Do(req) | 	resp, err := http.DefaultClient.Do(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
|  | @ -136,6 +137,7 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err | ||||||
| 	msgChan := make(chan *Message) | 	msgChan := make(chan *Message) | ||||||
| 	errChan := make(chan error) | 	errChan := make(chan error) | ||||||
| 	topicURL := c.expandTopicURL(topic) | 	topicURL := c.expandTopicURL(topic) | ||||||
|  | 	log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) | ||||||
| 	options = append(options, WithPoll()) | 	options = append(options, WithPoll()) | ||||||
| 	go func() { | 	go func() { | ||||||
| 		err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) | 		err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) | ||||||
|  | @ -171,6 +173,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { | ||||||
| 	defer c.mu.Unlock() | 	defer c.mu.Unlock() | ||||||
| 	subscriptionID := util.RandomString(10) | 	subscriptionID := util.RandomString(10) | ||||||
| 	topicURL := c.expandTopicURL(topic) | 	topicURL := c.expandTopicURL(topic) | ||||||
|  | 	log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) | ||||||
| 	ctx, cancel := context.WithCancel(context.Background()) | 	ctx, cancel := context.WithCancel(context.Background()) | ||||||
| 	c.subscriptions[subscriptionID] = &subscription{ | 	c.subscriptions[subscriptionID] = &subscription{ | ||||||
| 		ID:       subscriptionID, | 		ID:       subscriptionID, | ||||||
|  | @ -226,11 +229,11 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR | ||||||
| 		// TODO The retry logic is crude and may lose messages. It should record the last message like the | 		// TODO The retry logic is crude and may lose messages. It should record the last message like the | ||||||
| 		//      Android client, use since=, and do incremental backoff too | 		//      Android client, use since=, and do incremental backoff too | ||||||
| 		if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { | 		if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { | ||||||
| 			log.Printf("Connection to %s failed: %s", topicURL, err.Error()) | 			log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error()) | ||||||
| 		} | 		} | ||||||
| 		select { | 		select { | ||||||
| 		case <-ctx.Done(): | 		case <-ctx.Done(): | ||||||
| 			log.Printf("Connection to %s exited", topicURL) | 			log.Info("%s Connection exited", util.ShortTopicURL(topicURL)) | ||||||
| 			return | 			return | ||||||
| 		case <-time.After(10 * time.Second): // TODO Add incremental backoff | 		case <-time.After(10 * time.Second): // TODO Add incremental backoff | ||||||
| 		} | 		} | ||||||
|  | @ -238,7 +241,9 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { | func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { | ||||||
| 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil) | 	streamURL := fmt.Sprintf("%s/json", topicURL) | ||||||
|  | 	log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL) | ||||||
|  | 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -261,10 +266,12 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR | ||||||
| 	} | 	} | ||||||
| 	scanner := bufio.NewScanner(resp.Body) | 	scanner := bufio.NewScanner(resp.Body) | ||||||
| 	for scanner.Scan() { | 	for scanner.Scan() { | ||||||
| 		m, err := toMessage(scanner.Text(), topicURL, subscriptionID) | 		messageJSON := scanner.Text() | ||||||
|  | 		m, err := toMessage(messageJSON, topicURL, subscriptionID) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  | 		log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON) | ||||||
| 		if m.Event == MessageEvent { | 		if m.Event == MessageEvent { | ||||||
| 			msgChan <- m | 			msgChan <- m | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -19,7 +19,7 @@ const ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var flagsAccess = append( | var flagsAccess = append( | ||||||
| 	userCommandFlags(), | 	flagsUser, | ||||||
| 	&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, | 	&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -28,7 +28,7 @@ var cmdAccess = &cli.Command{ | ||||||
| 	Usage:     "Grant/revoke access to a topic, or show access", | 	Usage:     "Grant/revoke access to a topic, or show access", | ||||||
| 	UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", | 	UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", | ||||||
| 	Flags:     flagsAccess, | 	Flags:     flagsAccess, | ||||||
| 	Before:    initConfigFileInputSourceFunc("config", flagsAccess), | 	Before:    initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc), | ||||||
| 	Action:    execUserAccess, | 	Action:    execUserAccess, | ||||||
| 	Category:  categoryServer, | 	Category:  categoryServer, | ||||||
| 	Description: `Manage the access control list for the ntfy server. | 	Description: `Manage the access control list for the ntfy server. | ||||||
|  |  | ||||||
							
								
								
									
										25
									
								
								cmd/app.go
									
										
									
									
									
								
							
							
						
						
									
										25
									
								
								cmd/app.go
									
										
									
									
									
								
							|  | @ -3,6 +3,8 @@ package cmd | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"github.com/urfave/cli/v2" | 	"github.com/urfave/cli/v2" | ||||||
|  | 	"github.com/urfave/cli/v2/altsrc" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"os" | 	"os" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -13,6 +15,13 @@ const ( | ||||||
| 
 | 
 | ||||||
| var commands = make([]*cli.Command, 0) | var commands = make([]*cli.Command, 0) | ||||||
| 
 | 
 | ||||||
|  | var flagsDefault = []cli.Flag{ | ||||||
|  | 	&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"}, | ||||||
|  | 	&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"}, | ||||||
|  | 	&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"}, | ||||||
|  | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}), | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // New creates a new CLI application | // New creates a new CLI application | ||||||
| func New() *cli.App { | func New() *cli.App { | ||||||
| 	return &cli.App{ | 	return &cli.App{ | ||||||
|  | @ -25,5 +34,21 @@ func New() *cli.App { | ||||||
| 		Writer:                 os.Stdout, | 		Writer:                 os.Stdout, | ||||||
| 		ErrWriter:              os.Stderr, | 		ErrWriter:              os.Stderr, | ||||||
| 		Commands:               commands, | 		Commands:               commands, | ||||||
|  | 		Flags:                  flagsDefault, | ||||||
|  | 		Before:                 initLogFunc, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func initLogFunc(c *cli.Context) error { | ||||||
|  | 	if c.Bool("trace") { | ||||||
|  | 		log.SetLevel(log.TraceLevel) | ||||||
|  | 	} else if c.Bool("debug") { | ||||||
|  | 		log.SetLevel(log.DebugLevel) | ||||||
|  | 	} else { | ||||||
|  | 		log.SetLevel(log.ToLevel(c.String("log-level"))) | ||||||
|  | 	} | ||||||
|  | 	if c.Bool("no-log-dates") { | ||||||
|  | 		log.DisableDates() | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ import ( | ||||||
| 
 | 
 | ||||||
| // initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks | // initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks | ||||||
| // if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. | // if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. | ||||||
| func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag) cli.BeforeFunc { | func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc { | ||||||
| 	return func(context *cli.Context) error { | 	return func(context *cli.Context) error { | ||||||
| 		configFile := context.String(configFlag) | 		configFile := context.String(configFlag) | ||||||
| 		if context.IsSet(configFlag) && !util.FileExists(configFile) { | 		if context.IsSet(configFlag) && !util.FileExists(configFile) { | ||||||
|  | @ -23,7 +23,15 @@ func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag) cli.Befo | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		return altsrc.ApplyInputSourceValues(context, inputSource, flags) | 		if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		if next != nil { | ||||||
|  | 			if err := next(context); err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		return nil | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,31 +16,35 @@ func init() { | ||||||
| 	commands = append(commands, cmdPublish) | 	commands = append(commands, cmdPublish) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | var flagsPublish = append( | ||||||
|  | 	flagsDefault, | ||||||
|  | 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, | ||||||
|  | 	&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, | ||||||
|  | 	&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, | ||||||
|  | 	&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, | ||||||
|  | 	&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, | ||||||
|  | 	&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, | ||||||
|  | 	&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, | ||||||
|  | 	&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, | ||||||
|  | 	&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, | ||||||
|  | 	&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, | ||||||
|  | 	&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, | ||||||
|  | 	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, | ||||||
|  | 	&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, | ||||||
|  | 	&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, | ||||||
|  | 	&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"}, | ||||||
|  | 	&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"}, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| var cmdPublish = &cli.Command{ | var cmdPublish = &cli.Command{ | ||||||
| 	Name:      "publish", | 	Name:      "publish", | ||||||
| 	Aliases:   []string{"pub", "send", "trigger"}, | 	Aliases:   []string{"pub", "send", "trigger"}, | ||||||
| 	Usage:     "Send message via a ntfy server", | 	Usage:     "Send message via a ntfy server", | ||||||
| 	UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n   NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]", | 	UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]", | ||||||
| 	Action:    execPublish, | 	Action:    execPublish, | ||||||
| 	Category:  categoryClient, | 	Category:  categoryClient, | ||||||
| 	Flags: []cli.Flag{ | 	Flags:     flagsPublish, | ||||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, | 	Before:    initLogFunc, | ||||||
| 		&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, |  | ||||||
| 		&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, |  | ||||||
| 		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, |  | ||||||
| 		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, |  | ||||||
| 		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, |  | ||||||
| 		&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, |  | ||||||
| 		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, |  | ||||||
| 		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, |  | ||||||
| 		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, |  | ||||||
| 		&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, |  | ||||||
| 		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, |  | ||||||
| 		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, |  | ||||||
| 		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, |  | ||||||
| 		&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"}, |  | ||||||
| 		&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"}, |  | ||||||
| 	}, |  | ||||||
| 	Description: `Publish a message to a ntfy server. | 	Description: `Publish a message to a ntfy server. | ||||||
| 
 | 
 | ||||||
| Examples: | Examples: | ||||||
|  |  | ||||||
							
								
								
									
										60
									
								
								cmd/serve.go
									
										
									
									
									
								
							
							
						
						
									
										60
									
								
								cmd/serve.go
									
										
									
									
									
								
							|  | @ -5,10 +5,13 @@ package cmd | ||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log" | 	"heckel.io/ntfy/log" | ||||||
| 	"math" | 	"math" | ||||||
| 	"net" | 	"net" | ||||||
|  | 	"os" | ||||||
|  | 	"os/signal" | ||||||
| 	"strings" | 	"strings" | ||||||
|  | 	"syscall" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/urfave/cli/v2" | 	"github.com/urfave/cli/v2" | ||||||
|  | @ -21,8 +24,13 @@ func init() { | ||||||
| 	commands = append(commands, cmdServe) | 	commands = append(commands, cmdServe) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| var flagsServe = []cli.Flag{ | const ( | ||||||
| 	&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"}, | 	defaultServerConfigFile = "/etc/ntfy/server.yml" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var flagsServe = append( | ||||||
|  | 	flagsDefault, | ||||||
|  | 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, | ||||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), | ||||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), | ||||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), | ||||||
|  | @ -59,7 +67,7 @@ var flagsServe = []cli.Flag{ | ||||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), | 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), | ||||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), | 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), | ||||||
| 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), | 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), | ||||||
| } | ) | ||||||
| 
 | 
 | ||||||
| var cmdServe = &cli.Command{ | var cmdServe = &cli.Command{ | ||||||
| 	Name:      "serve", | 	Name:      "serve", | ||||||
|  | @ -68,7 +76,7 @@ var cmdServe = &cli.Command{ | ||||||
| 	Action:    execServe, | 	Action:    execServe, | ||||||
| 	Category:  categoryServer, | 	Category:  categoryServer, | ||||||
| 	Flags:     flagsServe, | 	Flags:     flagsServe, | ||||||
| 	Before:    initConfigFileInputSourceFunc("config", flagsServe), | 	Before:    initConfigFileInputSourceFunc("config", flagsServe, initLogFunc), | ||||||
| 	Description: `Run the ntfy server and listen for incoming requests | 	Description: `Run the ntfy server and listen for incoming requests | ||||||
| 
 | 
 | ||||||
| The command will load the configuration from /etc/ntfy/server.yml. Config options can  | The command will load the configuration from /etc/ntfy/server.yml. Config options can  | ||||||
|  | @ -85,6 +93,7 @@ func execServe(c *cli.Context) error { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Read all the options | 	// Read all the options | ||||||
|  | 	config := c.String("config") | ||||||
| 	baseURL := c.String("base-url") | 	baseURL := c.String("base-url") | ||||||
| 	listenHTTP := c.String("listen-http") | 	listenHTTP := c.String("listen-http") | ||||||
| 	listenHTTPS := c.String("listen-https") | 	listenHTTPS := c.String("listen-https") | ||||||
|  | @ -192,7 +201,7 @@ func execServe(c *cli.Context) error { | ||||||
| 	for _, host := range visitorRequestLimitExemptHosts { | 	for _, host := range visitorRequestLimitExemptHosts { | ||||||
| 		ips, err := net.LookupIP(host) | 		ips, err := net.LookupIP(host) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) | 			log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 		for _, ip := range ips { | 		for _, ip := range ips { | ||||||
|  | @ -240,14 +249,18 @@ func execServe(c *cli.Context) error { | ||||||
| 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish | 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish | ||||||
| 	conf.BehindProxy = behindProxy | 	conf.BehindProxy = behindProxy | ||||||
| 	conf.EnableWeb = enableWeb | 	conf.EnableWeb = enableWeb | ||||||
|  | 
 | ||||||
|  | 	// Set up hot-reloading of config | ||||||
|  | 	go sigHandlerConfigReload(config) | ||||||
|  | 
 | ||||||
|  | 	// Run server | ||||||
| 	s, err := server.New(conf) | 	s, err := server.New(conf) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatalln(err) | 		log.Fatal(err) | ||||||
|  | 	} else if err := s.Run(); err != nil { | ||||||
|  | 		log.Fatal(err) | ||||||
| 	} | 	} | ||||||
| 	if err := s.Run(); err != nil { | 	log.Info("Exiting.") | ||||||
| 		log.Fatalln(err) |  | ||||||
| 	} |  | ||||||
| 	log.Printf("Exiting.") |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -261,3 +274,28 @@ func parseSize(s string, defaultValue int64) (v int64, err error) { | ||||||
| 	} | 	} | ||||||
| 	return v, nil | 	return v, nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func sigHandlerConfigReload(config string) { | ||||||
|  | 	sigs := make(chan os.Signal, 1) | ||||||
|  | 	signal.Notify(sigs, syscall.SIGHUP) | ||||||
|  | 	for range sigs { | ||||||
|  | 		log.Info("Partially hot reloading configuration ...") | ||||||
|  | 		inputSource, err := newYamlSourceFromFile(config, flagsServe) | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Warn("Hot reload failed: %s", err.Error()) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		reloadLogLevel(inputSource) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func reloadLogLevel(inputSource altsrc.InputSourceContext) { | ||||||
|  | 	newLevelStr, err := inputSource.String("log-level") | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Warn("Cannot load log level: %s", err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	newLevel := log.ToLevel(newLevelStr) | ||||||
|  | 	log.SetLevel(newLevel) | ||||||
|  | 	log.Info("Log level is %s", newLevel.String()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -5,12 +5,13 @@ import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/urfave/cli/v2" | 	"github.com/urfave/cli/v2" | ||||||
| 	"heckel.io/ntfy/client" | 	"heckel.io/ntfy/client" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
| 	"log" |  | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" | 	"os/exec" | ||||||
| 	"os/user" | 	"os/user" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  | 	"sort" | ||||||
| 	"strings" | 	"strings" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -24,6 +25,16 @@ const ( | ||||||
| 	clientUserConfigFileWindowsRelative = "ntfy\\client.yml" | 	clientUserConfigFileWindowsRelative = "ntfy\\client.yml" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | var flagsSubscribe = append( | ||||||
|  | 	flagsDefault, | ||||||
|  | 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, | ||||||
|  | 	&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, | ||||||
|  | 	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"}, | ||||||
|  | 	&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"}, | ||||||
|  | 	&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, | ||||||
|  | 	&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| var cmdSubscribe = &cli.Command{ | var cmdSubscribe = &cli.Command{ | ||||||
| 	Name:      "subscribe", | 	Name:      "subscribe", | ||||||
| 	Aliases:   []string{"sub"}, | 	Aliases:   []string{"sub"}, | ||||||
|  | @ -31,15 +42,8 @@ var cmdSubscribe = &cli.Command{ | ||||||
| 	UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", | 	UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", | ||||||
| 	Action:    execSubscribe, | 	Action:    execSubscribe, | ||||||
| 	Category:  categoryClient, | 	Category:  categoryClient, | ||||||
| 	Flags: []cli.Flag{ | 	Flags:     flagsSubscribe, | ||||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, | 	Before:    initLogFunc, | ||||||
| 		&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, |  | ||||||
| 		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"}, |  | ||||||
| 		&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"}, |  | ||||||
| 		&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, |  | ||||||
| 		&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, |  | ||||||
| 		&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"}, |  | ||||||
| 	}, |  | ||||||
| 	Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for  | 	Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for  | ||||||
| every arriving message. There are 3 modes in which the command can be run: | every arriving message. There are 3 modes in which the command can be run: | ||||||
| 
 | 
 | ||||||
|  | @ -186,6 +190,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, | ||||||
| 		if !ok { | 		if !ok { | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  | 		log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw) | ||||||
| 		printMessageOrRunCommand(c, m, cmd) | 		printMessageOrRunCommand(c, m, cmd) | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
|  | @ -195,26 +200,26 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) | ||||||
| 	if command != "" { | 	if command != "" { | ||||||
| 		runCommand(c, command, m) | 		runCommand(c, command, m) | ||||||
| 	} else { | 	} else { | ||||||
|  | 		log.Debug("%s Printing raw message", logMessagePrefix(m)) | ||||||
| 		fmt.Fprintln(c.App.Writer, m.Raw) | 		fmt.Fprintln(c.App.Writer, m.Raw) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func runCommand(c *cli.Context, command string, m *client.Message) { | func runCommand(c *cli.Context, command string, m *client.Message) { | ||||||
| 	if err := runCommandInternal(c, command, m); err != nil { | 	if err := runCommandInternal(c, command, m); err != nil { | ||||||
| 		fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error()) | 		log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error()) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func runCommandInternal(c *cli.Context, script string, m *client.Message) error { | func runCommandInternal(c *cli.Context, script string, m *client.Message) error { | ||||||
| 	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt) | 	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt) | ||||||
| 	if err := os.WriteFile(scriptFile, []byte(scriptHeader+script), 0700); err != nil { | 	log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile) | ||||||
|  | 	script = scriptHeader + script | ||||||
|  | 	if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	defer os.Remove(scriptFile) | 	defer os.Remove(scriptFile) | ||||||
| 	verbose := c.Bool("verbose") | 	log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile) | ||||||
| 	if verbose { |  | ||||||
| 		log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), script, m.Raw) |  | ||||||
| 	} |  | ||||||
| 	cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...) | 	cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...) | ||||||
| 	cmd.Stdin = c.App.Reader | 	cmd.Stdin = c.App.Reader | ||||||
| 	cmd.Stdout = c.App.Writer | 	cmd.Stdout = c.App.Writer | ||||||
|  | @ -224,7 +229,7 @@ func runCommandInternal(c *cli.Context, script string, m *client.Message) error | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func envVars(m *client.Message) []string { | func envVars(m *client.Message) []string { | ||||||
| 	env := os.Environ() | 	env := make([]string, 0) | ||||||
| 	env = append(env, envVar(m.ID, "NTFY_ID", "id")...) | 	env = append(env, envVar(m.ID, "NTFY_ID", "id")...) | ||||||
| 	env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) | 	env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) | ||||||
| 	env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) | 	env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) | ||||||
|  | @ -233,7 +238,11 @@ func envVars(m *client.Message) []string { | ||||||
| 	env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) | 	env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) | ||||||
| 	env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) | 	env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) | ||||||
| 	env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) | 	env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) | ||||||
| 	return env | 	sort.Strings(env) | ||||||
|  | 	if log.IsTrace() { | ||||||
|  | 		log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n")) | ||||||
|  | 	} | ||||||
|  | 	return append(os.Environ(), env...) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func envVar(value string, vars ...string) []string { | func envVar(value string, vars ...string) []string { | ||||||
|  | @ -249,7 +258,7 @@ func loadConfig(c *cli.Context) (*client.Config, error) { | ||||||
| 	if filename != "" { | 	if filename != "" { | ||||||
| 		return client.LoadConfig(filename) | 		return client.LoadConfig(filename) | ||||||
| 	} | 	} | ||||||
| 	configFile := defaultConfigFile() | 	configFile := defaultClientConfigFile() | ||||||
| 	if s, _ := os.Stat(configFile); s != nil { | 	if s, _ := os.Stat(configFile); s != nil { | ||||||
| 		return client.LoadConfig(configFile) | 		return client.LoadConfig(configFile) | ||||||
| 	} | 	} | ||||||
|  | @ -257,7 +266,7 @@ func loadConfig(c *cli.Context) (*client.Config, error) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| //lint:ignore U1000 Conditionally used in different builds | //lint:ignore U1000 Conditionally used in different builds | ||||||
| func defaultConfigFileUnix() string { | func defaultClientConfigFileUnix() string { | ||||||
| 	u, _ := user.Current() | 	u, _ := user.Current() | ||||||
| 	configFile := clientRootConfigFileUnixAbsolute | 	configFile := clientRootConfigFileUnixAbsolute | ||||||
| 	if u.Uid != "0" { | 	if u.Uid != "0" { | ||||||
|  | @ -268,7 +277,11 @@ func defaultConfigFileUnix() string { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| //lint:ignore U1000 Conditionally used in different builds | //lint:ignore U1000 Conditionally used in different builds | ||||||
| func defaultConfigFileWindows() string { | func defaultClientConfigFileWindows() string { | ||||||
| 	homeDir, _ := os.UserConfigDir() | 	homeDir, _ := os.UserConfigDir() | ||||||
| 	return filepath.Join(homeDir, clientUserConfigFileWindowsRelative) | 	return filepath.Join(homeDir, clientUserConfigFileWindowsRelative) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func logMessagePrefix(m *client.Message) string { | ||||||
|  | 	return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -11,6 +11,6 @@ var ( | ||||||
| 	scriptLauncher = []string{"sh", "-c"} | 	scriptLauncher = []string{"sh", "-c"} | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func defaultConfigFile() string { | func defaultClientConfigFile() string { | ||||||
| 	return defaultConfigFileUnix() | 	return defaultClientConfigFileUnix() | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -11,6 +11,6 @@ var ( | ||||||
| 	scriptLauncher = []string{"sh", "-c"} | 	scriptLauncher = []string{"sh", "-c"} | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func defaultConfigFile() string { | func defaultClientConfigFile() string { | ||||||
| 	return defaultConfigFileUnix() | 	return defaultClientConfigFileUnix() | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -10,6 +10,6 @@ var ( | ||||||
| 	scriptLauncher = []string{"cmd.exe", "/Q", "/C"} | 	scriptLauncher = []string{"cmd.exe", "/Q", "/C"} | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func defaultConfigFile() string { | func defaultClientConfigFile() string { | ||||||
| 	return defaultConfigFileWindows() | 	return defaultClientConfigFileWindows() | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								cmd/user.go
									
										
									
									
									
								
							
							
						
						
									
										17
									
								
								cmd/user.go
									
										
									
									
									
								
							|  | @ -17,14 +17,19 @@ func init() { | ||||||
| 	commands = append(commands, cmdUser) | 	commands = append(commands, cmdUser) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| var flagsUser = userCommandFlags() | var flagsUser = append( | ||||||
|  | 	flagsDefault, | ||||||
|  | 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, | ||||||
|  | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), | ||||||
|  | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| var cmdUser = &cli.Command{ | var cmdUser = &cli.Command{ | ||||||
| 	Name:      "user", | 	Name:      "user", | ||||||
| 	Usage:     "Manage/show users", | 	Usage:     "Manage/show users", | ||||||
| 	UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", | 	UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", | ||||||
| 	Flags:     flagsUser, | 	Flags:     flagsUser, | ||||||
| 	Before:    initConfigFileInputSourceFunc("config", flagsUser), | 	Before:    initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), | ||||||
| 	Category:  categoryServer, | 	Category:  categoryServer, | ||||||
| 	Subcommands: []*cli.Command{ | 	Subcommands: []*cli.Command{ | ||||||
| 		{ | 		{ | ||||||
|  | @ -269,11 +274,3 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) { | ||||||
| 	} | 	} | ||||||
| 	return string(password), nil | 	return string(password), nil | ||||||
| } | } | ||||||
| 
 |  | ||||||
| func userCommandFlags() []cli.Flag { |  | ||||||
| 	return []cli.Flag{ |  | ||||||
| 		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"}, |  | ||||||
| 		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), |  | ||||||
| 		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								docker-compose.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								docker-compose.yml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | ||||||
|  | version: "2.1" | ||||||
|  | services: | ||||||
|  |   ntfy: | ||||||
|  |     image: binwiederhier/ntfy | ||||||
|  |     container_name: ntfy | ||||||
|  |     command: | ||||||
|  |       - serve | ||||||
|  |     environment: | ||||||
|  |       - TZ=UTC    # optional: Change to your desired timezone | ||||||
|  |     user: UID:GID # optional: Set custom user/group or uid/gid | ||||||
|  |     volumes: | ||||||
|  |       - /var/cache/ntfy:/var/cache/ntfy | ||||||
|  |       - /etc/ntfy:/etc/ntfy | ||||||
|  |     ports: | ||||||
|  |       - 80:80 | ||||||
|  |     restart: unless-stopped | ||||||
|  | 
 | ||||||
							
								
								
									
										180
									
								
								docs/config.md
									
										
									
									
									
								
							
							
						
						
									
										180
									
								
								docs/config.md
									
										
									
									
									
								
							|  | @ -643,10 +643,18 @@ In case you're curious, here's an example of the entire flow: | ||||||
| - In the iOS app, you subscribe to `https://ntfy.example.com/mytopic` | - In the iOS app, you subscribe to `https://ntfy.example.com/mytopic` | ||||||
| - The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL) | - The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL) | ||||||
| - When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a  | - When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a  | ||||||
|   poll request to `https://ntfy.sh/6de73be8dfb7d69e...` (passing the message ID in the `X-Poll-ID` header) |   poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server  | ||||||
| - The ntfy.sh server publishes the message to Firebase, which forwards it to APNS, which forwards it to your iOS device |   contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic). | ||||||
|  | - The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device | ||||||
| - Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it | - Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it | ||||||
| 
 | 
 | ||||||
|  | Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b | ||||||
|  | {"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"} | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ## Rate limiting | ## Rate limiting | ||||||
| !!! info | !!! info | ||||||
|     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.  |     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.  | ||||||
|  | @ -700,6 +708,23 @@ are enabled): | ||||||
| * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. | * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. | ||||||
| * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. | * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. | ||||||
| 
 | 
 | ||||||
|  | ### Firebase limits | ||||||
|  | If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no`  | ||||||
|  | is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling) | ||||||
|  | on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time  | ||||||
|  | of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to  | ||||||
|  | the same topic**.  | ||||||
|  | 
 | ||||||
|  | In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message | ||||||
|  | is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously, | ||||||
|  | there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected. | ||||||
|  | After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor. | ||||||
|  | 
 | ||||||
|  | If this ever happens, there will be a log message that looks something like this: | ||||||
|  | ``` | ||||||
|  | WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ## Tuning for scale | ## Tuning for scale | ||||||
| If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, | If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, | ||||||
| if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. | if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. | ||||||
|  | @ -799,6 +824,26 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a | ||||||
|     maxretry = 10 |     maxretry = 10 | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
|  | ## Debugging/tracing | ||||||
|  | If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level` | ||||||
|  | to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message  | ||||||
|  | contents. The `TRACE` setting will also print the message contents.  | ||||||
|  | 
 | ||||||
|  | !!! warning | ||||||
|  |     Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,  | ||||||
|  |     you're going to run out of disk space pretty quickly. | ||||||
|  | 
 | ||||||
|  | You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file. | ||||||
|  | You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.  | ||||||
|  | If successful, you'll see something like this: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | $ ntfy serve | ||||||
|  | 2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO | ||||||
|  | 2022/06/02 10:29:34 INFO Partially hot reloading configuration ... | ||||||
|  | 2022/06/02 10:29:34 INFO Log level is TRACE | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ## Config options | ## Config options | ||||||
| Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a | Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a | ||||||
| CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment | CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment | ||||||
|  | @ -809,43 +854,44 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | ||||||
|     `cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do  |     `cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do  | ||||||
|     not support dashes. |     not support dashes. | ||||||
| 
 | 
 | ||||||
| | Config option                              | Env variable                                    | Format                                              | Default      | Description                                                                                                                                                                                                                     | | | Config option                              | Env variable                                    | Format                                              | Default           | Description                                                                                                                                                                                                                     | | ||||||
| |--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | |--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ||||||
| | `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -            | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  | | | `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -                 | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  | | ||||||
| | `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`        | Listen address for the HTTP web server                                                                                                                                                                                          | | | `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`             | Listen address for the HTTP web server                                                                                                                                                                                          | | ||||||
| | `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -            | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               | | | `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -                 | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               | | ||||||
| | `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -            | Path to a Unix socket to listen on                                                                                                                                                                                              | | | `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -                 | Path to a Unix socket to listen on                                                                                                                                                                                              | | ||||||
| | `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -            | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 | | | `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -                 | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||||
| | `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -            | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 | | | `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -                 | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||||
| | `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -            | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm).                        | | | `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -                 | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm).                        | | ||||||
| | `cache-file`                               | `NTFY_CACHE_FILE`                               | *filename*                                          | -            | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache).             | | | `cache-file`                               | `NTFY_CACHE_FILE`                               | *filename*                                          | -                 | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache).             | | ||||||
| | `cache-duration`                           | `NTFY_CACHE_DURATION`                           | *duration*                                          | 12h          | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely.                                        | | | `cache-duration`                           | `NTFY_CACHE_DURATION`                           | *duration*                                          | 12h               | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely.                                        | | ||||||
| | `auth-file`                                | `NTFY_AUTH_FILE`                                | *filename*                                          | -            | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control).                                                                                           | | | `auth-file`                                | `NTFY_AUTH_FILE`                                | *filename*                                          | -                 | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control).                                                                                           | | ||||||
| | `auth-default-access`                      | `NTFY_AUTH_DEFAULT_ACCESS`                      | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`.                                                                                                                             | | | `auth-default-access`                      | `NTFY_AUTH_DEFAULT_ACCESS`                      | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write`      | Default permissions if no matching entries in the auth database are found. Default is `read-write`.                                                                                                                             | | ||||||
| | `behind-proxy`                             | `NTFY_BEHIND_PROXY`                             | *bool*                                              | false        | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection.                                                                                                 | | | `behind-proxy`                             | `NTFY_BEHIND_PROXY`                             | *bool*                                              | false             | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection.                                                                                                 | | ||||||
| | `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -            | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  | | | `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -                 | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  | | ||||||
| | `attachment-total-size-limit`              | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT`              | *size*                                              | 5G           | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected.                                                                                                                   | | | `attachment-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-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`.                                                                                               | | | `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-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-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-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-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-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-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-`                                                                                                                                                          | | | `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. | | | `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.                                                                                                                                         | | | `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`, `home` or `disable`                          | `app`        | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable)                                                                                                                                  | | | `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000            | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     | | ||||||
| | `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000       | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     | | | `upstream-base-url`                        | `NTFY_UPSTREAM_BASE_URL`                        | *URL*                                               | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers                                                                                                                   | | ||||||
| | `visitor-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-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-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size*                                              | 500M         | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding.                                                                                        | | | `visitor-email-limit-burst`                | `NTFY_VISITOR_EMAIL_LIMIT_BURST`                | *number*                                            | 16                | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              | | ||||||
| | `visitor-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-email-limit-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*                                          | 1h                | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        | | ||||||
| | `visitor-request-limit-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-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-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-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-email-limit-burst`                | `NTFY_VISITOR_EMAIL_LIMIT_BURST`                | *number*                                            | 16           | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              | | | `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-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*                                          | 1h           | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        | | | `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30                | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 | | ||||||
|  | | `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app`, `home` or `disable`                          | `app`             | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable)                                                                                                                                  | | ||||||
| 
 | 
 | ||||||
| The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.    | The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.    | ||||||
| The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k. | The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k. | ||||||
|  | @ -873,42 +919,46 @@ DESCRIPTION: | ||||||
|      ntfy serve --listen-http :8080  # Starts server with alternate port |      ntfy serve --listen-http :8080  # Starts server with alternate port | ||||||
| 
 | 
 | ||||||
| OPTIONS: | OPTIONS: | ||||||
|    --config value, -c value                                                                            config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] |    --attachment-cache-dir value, --attachment_cache_dir value                                          cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] | ||||||
|  |    --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                    duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] | ||||||
|  |    --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                    per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] | ||||||
|  |    --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                  limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] | ||||||
|  |    --auth-default-access value, --auth_default_access value, -p value                                  default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] | ||||||
|  |    --auth-file value, --auth_file value, -H value                                                      auth database file used for access control [$NTFY_AUTH_FILE] | ||||||
|    --base-url value, --base_url value, -B value                                                        externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] |    --base-url value, --base_url value, -B value                                                        externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] | ||||||
|  |    --behind-proxy, --behind_proxy, -P                                                                  if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] | ||||||
|  |    --cache-duration since, --cache_duration since, -b since                                            buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] | ||||||
|  |    --cache-file value, --cache_file value, -C value                                                    cache file used for message caching [$NTFY_CACHE_FILE] | ||||||
|  |    --cert-file value, --cert_file value, -E value                                                      certificate file, if listen-https is set [$NTFY_CERT_FILE] | ||||||
|  |    --config value, -c value                                                                            config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] | ||||||
|  |    --debug, -d                                                                                         enable debug logging (default: false) [$NTFY_DEBUG] | ||||||
|  |    --firebase-key-file value, --firebase_key_file value, -F value                                      Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] | ||||||
|  |    --global-topic-limit value, --global_topic_limit value, -T value                                    total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] | ||||||
|  |    --keepalive-interval value, --keepalive_interval value, -k value                                    interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] | ||||||
|  |    --key-file value, --key_file value, -K value                                                        private key file, if listen-https is set [$NTFY_KEY_FILE] | ||||||
|    --listen-http value, --listen_http value, -l value                                                  ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] |    --listen-http value, --listen_http value, -l value                                                  ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] | ||||||
|    --listen-https value, --listen_https value, -L value                                                ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] |    --listen-https value, --listen_https value, -L value                                                ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] | ||||||
|    --listen-unix value, --listen_unix value, -U value                                                  listen on unix socket path [$NTFY_LISTEN_UNIX] |    --listen-unix value, --listen_unix value, -U value                                                  listen on unix socket path [$NTFY_LISTEN_UNIX] | ||||||
|    --key-file value, --key_file value, -K value                                                        private key file, if listen-https is set [$NTFY_KEY_FILE] |    --log-level value, --log_level value                                                                set log level (default: "INFO") [$NTFY_LOG_LEVEL] | ||||||
|    --cert-file value, --cert_file value, -E value                                                      certificate file, if listen-https is set [$NTFY_CERT_FILE] |  | ||||||
|    --firebase-key-file value, --firebase_key_file value, -F value                                      Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] |  | ||||||
|    --cache-file value, --cache_file value, -C value                                                    cache file used for message caching [$NTFY_CACHE_FILE] |  | ||||||
|    --cache-duration since, --cache_duration since, -b since                                            buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] |  | ||||||
|    --auth-file value, --auth_file value, -H value                                                      auth database file used for access control [$NTFY_AUTH_FILE] |  | ||||||
|    --auth-default-access value, --auth_default_access value, -p value                                  default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] |  | ||||||
|    --attachment-cache-dir value, --attachment_cache_dir value                                          cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] |  | ||||||
|    --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                  limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] |  | ||||||
|    --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                    per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] |  | ||||||
|    --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                    duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] |  | ||||||
|    --keepalive-interval value, --keepalive_interval value, -k value                                    interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] |  | ||||||
|    --manager-interval value, --manager_interval value, -m value                                        interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] |    --manager-interval value, --manager_interval value, -m value                                        interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] | ||||||
|    --web-root value, --web_root value                                                                  sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT] |    --no-log-dates, --no_log_dates                                                                      disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES] | ||||||
|    --smtp-sender-addr value, --smtp_sender_addr value                                                  SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] |    --smtp-sender-addr value, --smtp_sender_addr value                                                  SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] | ||||||
|    --smtp-sender-user value, --smtp_sender_user value                                                  SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] |  | ||||||
|    --smtp-sender-pass value, --smtp_sender_pass value                                                  SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] |  | ||||||
|    --smtp-sender-from value, --smtp_sender_from value                                                  SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] |    --smtp-sender-from value, --smtp_sender_from value                                                  SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] | ||||||
|    --smtp-server-listen value, --smtp_server_listen value                                              SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] |    --smtp-sender-pass value, --smtp_sender_pass value                                                  SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] | ||||||
|    --smtp-server-domain value, --smtp_server_domain value                                              SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] |    --smtp-sender-user value, --smtp_sender_user value                                                  SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] | ||||||
|    --smtp-server-addr-prefix value, --smtp_server_addr_prefix value                                    SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] |    --smtp-server-addr-prefix value, --smtp_server_addr_prefix value                                    SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] | ||||||
|    --global-topic-limit value, --global_topic_limit value, -T value                                    total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] |    --smtp-server-domain value, --smtp_server_domain value                                              SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] | ||||||
|    --visitor-subscription-limit value, --visitor_subscription_limit value                              number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] |    --smtp-server-listen value, --smtp_server_listen value                                              SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] | ||||||
|    --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value            total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] |    --trace                                                                                             enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE] | ||||||
|  |    --upstream-base-url value, --upstream_base_url value                                                forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL] | ||||||
|    --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value  total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] |    --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value  total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] | ||||||
|    --visitor-request-limit-burst value, --visitor_request_limit_burst value                            initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] |    --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value            total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] | ||||||
|    --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                    interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] |  | ||||||
|    --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value              hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] |  | ||||||
|    --visitor-email-limit-burst value, --visitor_email_limit_burst value                                initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] |    --visitor-email-limit-burst value, --visitor_email_limit_burst value                                initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] | ||||||
|    --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                        interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] |    --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                        interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] | ||||||
|    --behind-proxy, --behind_proxy, -P                                                                  if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] |    --visitor-request-limit-burst value, --visitor_request_limit_burst value                            initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] | ||||||
|    --help, -h                                                                                          show help (default: false) |    --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value              hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] | ||||||
|  |    --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                    interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] | ||||||
|  |    --visitor-subscription-limit value, --visitor_subscription_limit value                              number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] | ||||||
|  |    --web-root value, --web_root value                                                                  sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT] | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -44,7 +44,7 @@ fi | ||||||
| 
 | 
 | ||||||
| ## Server-sent messages in your web app | ## Server-sent messages in your web app | ||||||
| Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own | Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own | ||||||
| web application. Check out the <a href="/example.html">live example</a> or just look the source of this page. | web application. Check out the <a href="/example.html">live example</a>. | ||||||
| 
 | 
 | ||||||
| ## Notify on SSH login | ## Notify on SSH login | ||||||
| Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I | Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I | ||||||
|  |  | ||||||
|  | @ -26,37 +26,37 @@ deb/rpm packages. | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_x86_64.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_x86_64.tar.gz | ||||||
|     tar zxvf ntfy_1.24.0_linux_x86_64.tar.gz |     tar zxvf ntfy_1.25.2_linux_x86_64.tar.gz | ||||||
|     sudo cp -a ntfy_1.24.0_linux_x86_64/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_1.25.2_linux_x86_64/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_x86_64/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_x86_64/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv6" | === "armv6" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv6.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv6.tar.gz | ||||||
|     tar zxvf ntfy_1.24.0_linux_armv6.tar.gz |     tar zxvf ntfy_1.25.2_linux_armv6.tar.gz | ||||||
|     sudo cp -a ntfy_1.24.0_linux_armv6/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_1.25.2_linux_armv6/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_armv6/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv7.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv7.tar.gz | ||||||
|     tar zxvf ntfy_1.24.0_linux_armv7.tar.gz |     tar zxvf ntfy_1.25.2_linux_armv7.tar.gz | ||||||
|     sudo cp -a ntfy_1.24.0_linux_armv7/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_1.25.2_linux_armv7/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_armv7/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_arm64.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_arm64.tar.gz | ||||||
|     tar zxvf ntfy_1.24.0_linux_arm64.tar.gz |     tar zxvf ntfy_1.25.2_linux_arm64.tar.gz | ||||||
|     sudo cp -a ntfy_1.24.0_linux_arm64/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_1.25.2_linux_arm64/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_arm64/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
|  | @ -103,7 +103,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_amd64.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_amd64.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -111,7 +111,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "armv6" | === "armv6" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv6.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv6.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -119,7 +119,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv7.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv7.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -127,7 +127,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_arm64.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_arm64.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -137,28 +137,28 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_amd64.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_amd64.rpm | ||||||
|     sudo systemctl enable ntfy  |     sudo systemctl enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv6" | === "armv6" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv6.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv6.rpm | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv7.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv7.rpm | ||||||
|     sudo systemctl enable ntfy  |     sudo systemctl enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_arm64.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_arm64.rpm | ||||||
|     sudo systemctl enable ntfy  |     sudo systemctl enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
|  | @ -176,6 +176,12 @@ cd ntfysh-bin | ||||||
| makepkg -si | makepkg -si | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | ## NixOS / Nix | ||||||
|  | ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment: | ||||||
|  | ``` | ||||||
|  | nix-env -iA ntfy-sh | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ## macOS | ## macOS | ||||||
| The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.  | The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.  | ||||||
| To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).  | To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).  | ||||||
|  | @ -184,11 +190,11 @@ If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For a | ||||||
| `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). | `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
| curl https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_macOS_all.tar.gz > ntfy_1.24.0_macOS_all.tar.gz | curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_macOS_all.tar.gz > ntfy_1.25.2_macOS_all.tar.gz | ||||||
| tar zxvf ntfy_1.24.0_macOS_all.tar.gz | tar zxvf ntfy_1.25.2_macOS_all.tar.gz | ||||||
| sudo cp -a ntfy_1.24.0_macOS_all/ntfy /usr/local/bin/ntfy | sudo cp -a ntfy_1.25.2_macOS_all/ntfy /usr/local/bin/ntfy | ||||||
| mkdir ~/Library/Application\ Support/ntfy  | mkdir ~/Library/Application\ Support/ntfy  | ||||||
| cp ntfy_1.24.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml | cp ntfy_1.25.2_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml | ||||||
| ntfy --help | ntfy --help | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | @ -200,11 +206,15 @@ ntfy --help | ||||||
| 
 | 
 | ||||||
| ## Windows | ## Windows | ||||||
| The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. | The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. | ||||||
| To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_v1.24.0_windows_x86_64.zip), | To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_v1.25.2_windows_x86_64.zip), | ||||||
| extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.  | extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.  | ||||||
| 
 | 
 | ||||||
| The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). | The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). | ||||||
| 
 | 
 | ||||||
|  | Also available in [Scoop's](https://scoop.sh) Main repository: | ||||||
|  | 
 | ||||||
|  | `scoop install ntfy` | ||||||
|  | 
 | ||||||
| !!! info | !!! info | ||||||
|     There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a |     There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a | ||||||
|     [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. |     [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. | ||||||
|  | @ -233,17 +243,19 @@ docker run \ | ||||||
|     serve |     serve | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details): | With other config options, timezone, and non-root user (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 \ | ||||||
|  |   -e TZ=UTC \ | ||||||
|   -p 80:80 \ |   -p 80:80 \ | ||||||
|  |   -u UID:GID \ | ||||||
|   -it \ |   -it \ | ||||||
|   binwiederhier/ntfy \ |   binwiederhier/ntfy \ | ||||||
|   serve |   serve | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| Using docker-compose: | Using docker-compose with non-root user: | ||||||
| ```yaml | ```yaml | ||||||
| version: "2.1" | version: "2.1" | ||||||
| 
 | 
 | ||||||
|  | @ -253,6 +265,9 @@ services: | ||||||
|     container_name: ntfy |     container_name: ntfy | ||||||
|     command: |     command: | ||||||
|       - serve |       - serve | ||||||
|  |     environment: | ||||||
|  |       - TZ=UTC    # optional: set desired timezone | ||||||
|  |     user: UID:GID # optional: replace with your own user/group or uid/gid | ||||||
|     volumes: |     volumes: | ||||||
|       - /var/cache/ntfy:/var/cache/ntfy |       - /var/cache/ntfy:/var/cache/ntfy | ||||||
|       - /etc/ntfy:/etc/ntfy |       - /etc/ntfy:/etc/ntfy | ||||||
|  | @ -261,6 +276,8 @@ services: | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid. | ||||||
|  | 
 | ||||||
| Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately. | Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately. | ||||||
| ``` | ``` | ||||||
| FROM binwiederhier/ntfy | FROM binwiederhier/ntfy | ||||||
|  |  | ||||||
|  | @ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p | ||||||
| I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see | I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see | ||||||
| [FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version. | [FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version. | ||||||
| 
 | 
 | ||||||
| The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages, | For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics  | ||||||
| aside from a short on-disk cache to support service restarts. | or messages, though typically this is turned off. | ||||||
|  |  | ||||||
|  | @ -4,7 +4,67 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | ||||||
| 
 | 
 | ||||||
| <!-- | <!-- | ||||||
| 
 | 
 | ||||||
| ## ntfy iOS app v1.1 (UNRELEASED) | ## ntfy Android app v1.14.0 (UNRELEASED) | ||||||
|  | 
 | ||||||
|  | **Additional translations:** | ||||||
|  | 
 | ||||||
|  | * Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/)) | ||||||
|  | 
 | ||||||
|  | ## ntfy server v1.26.0 (UNRELEASED) | ||||||
|  | 
 | ||||||
|  | **Features:** | ||||||
|  | 
 | ||||||
|  | * Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu)) | ||||||
|  | 
 | ||||||
|  | --> | ||||||
|  | 
 | ||||||
|  | ## ntfy server v1.25.2 | ||||||
|  | Released June 2, 2022 | ||||||
|  | 
 | ||||||
|  | This release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a  | ||||||
|  | production problem with a few over-users that resulted in Firebase quota problems (only applying to the over-users).  | ||||||
|  | We now block visitors from using Firebase if they trigger a quota exceeded response. | ||||||
|  | 
 | ||||||
|  | On top of that, we updated the Firebase SDK and are now building the release in GitHub Actions. We've also got two | ||||||
|  | more translations: Chinese/Simplified and Dutch. | ||||||
|  | 
 | ||||||
|  | **Features:** | ||||||
|  | 
 | ||||||
|  | * Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284)) | ||||||
|  | 
 | ||||||
|  | **Bugs**: | ||||||
|  | 
 | ||||||
|  | * Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289)) | ||||||
|  | * Fix documentation header blue header due to mkdocs-material theme update (no ticket)  | ||||||
|  | 
 | ||||||
|  | **Maintenance:** | ||||||
|  | 
 | ||||||
|  | * Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274)) | ||||||
|  | * CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36)) | ||||||
|  | 
 | ||||||
|  | **Documentation**: | ||||||
|  | 
 | ||||||
|  | * ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket) | ||||||
|  | * [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs)) | ||||||
|  | * Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s)) | ||||||
|  | * Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting) | ||||||
|  | * Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl)) | ||||||
|  | 
 | ||||||
|  | **Additional translations:** | ||||||
|  | 
 | ||||||
|  | * Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/)) | ||||||
|  | * Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/)) | ||||||
|  | 
 | ||||||
|  | ## ntfy iOS app v1.1 | ||||||
|  | Released May 31, 2022 | ||||||
|  | 
 | ||||||
|  | In this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis, | ||||||
|  | action buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click | ||||||
|  | action when the notification is tapped, and various other fixes. | ||||||
|  | 
 | ||||||
|  | It also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be | ||||||
|  | configured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) | ||||||
|  | for details). | ||||||
| 
 | 
 | ||||||
| **Features:** | **Features:** | ||||||
| 
 | 
 | ||||||
|  | @ -21,22 +81,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | ||||||
| 
 | 
 | ||||||
| * iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)) | * iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)) | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| ## ntfy Android app v1.14.0 (UNRELEASED) |  | ||||||
| 
 |  | ||||||
| **Additional translations:** |  | ||||||
| 
 |  | ||||||
| * Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/)) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| ## ntfy server v1.25.0 (UNRELEASED) |  | ||||||
| 
 |  | ||||||
| **Documentation**: |  | ||||||
| 
 |  | ||||||
| * [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs)) |  | ||||||
| 
 |  | ||||||
| --> |  | ||||||
| 
 |  | ||||||
| ## ntfy server v1.24.0 | ## ntfy server v1.24.0 | ||||||
| Released May 28, 2022 | Released May 28, 2022 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								docs/static/css/extra.css
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								docs/static/css/extra.css
									
										
									
									
										vendored
									
									
								
							|  | @ -1,4 +1,4 @@ | ||||||
| :root { | :root > * { | ||||||
|     --md-primary-fg-color:        #338574; |     --md-primary-fg-color:        #338574; | ||||||
|     --md-primary-fg-color--light: #338574; |     --md-primary-fg-color--light: #338574; | ||||||
|     --md-primary-fg-color--dark:  #338574; |     --md-primary-fg-color--dark:  #338574; | ||||||
|  |  | ||||||
							
								
								
									
										8
									
								
								docs/static/js/extra.js
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								docs/static/js/extra.js
									
										
									
									
										vendored
									
									
								
							|  | @ -1,8 +1,8 @@ | ||||||
| // Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
 | // Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
 | ||||||
| 
 | 
 | ||||||
| const savedTab = localStorage.getItem('savedTab') | const savedCodeTab = localStorage.getItem('savedTab') | ||||||
| const tabs = document.querySelectorAll(".tabbed-set > input") | const codeTabs = document.querySelectorAll(".tabbed-set > input") | ||||||
| for (const tab of tabs) { | for (const tab of codeTabs) { | ||||||
|     tab.addEventListener("click", () => { |     tab.addEventListener("click", () => { | ||||||
|         const current = document.querySelector(`label[for=${tab.id}]`) |         const current = document.querySelector(`label[for=${tab.id}]`) | ||||||
|         const pos = current.getBoundingClientRect().top |         const pos = current.getBoundingClientRect().top | ||||||
|  | @ -25,7 +25,7 @@ for (const tab of tabs) { | ||||||
|     // Select saved tab
 |     // Select saved tab
 | ||||||
|     const current = document.querySelector(`label[for=${tab.id}]`) |     const current = document.querySelector(`label[for=${tab.id}]`) | ||||||
|     const labelContent = current.innerHTML |     const labelContent = current.innerHTML | ||||||
|     if (savedTab === labelContent) { |     if (savedCodeTab === labelContent) { | ||||||
|         tab.checked = true |         tab.checked = true | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						
									
										14
									
								
								go.mod
									
										
									
									
									
								
							|  | @ -5,7 +5,6 @@ go 1.17 | ||||||
| require ( | require ( | ||||||
| 	cloud.google.com/go/firestore v1.6.1 // indirect | 	cloud.google.com/go/firestore v1.6.1 // indirect | ||||||
| 	cloud.google.com/go/storage v1.22.1 // indirect | 	cloud.google.com/go/storage v1.22.1 // indirect | ||||||
| 	firebase.google.com/go v3.13.0+incompatible |  | ||||||
| 	github.com/BurntSushi/toml v1.1.0 // indirect | 	github.com/BurntSushi/toml v1.1.0 // indirect | ||||||
| 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect | 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect | ||||||
| 	github.com/emersion/go-smtp v0.15.0 | 	github.com/emersion/go-smtp v0.15.0 | ||||||
|  | @ -17,15 +16,17 @@ require ( | ||||||
| 	github.com/urfave/cli/v2 v2.8.1 | 	github.com/urfave/cli/v2 v2.8.1 | ||||||
| 	golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e | 	golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e | ||||||
| 	golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect | 	golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect | ||||||
| 	golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 | 	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f | ||||||
| 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 | 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 | ||||||
| 	golang.org/x/time v0.0.0-20220411224347-583f2d630306 | 	golang.org/x/time v0.0.0-20220411224347-583f2d630306 | ||||||
| 	google.golang.org/api v0.81.0 | 	google.golang.org/api v0.82.0 | ||||||
| 	gopkg.in/yaml.v2 v2.4.0 | 	gopkg.in/yaml.v2 v2.4.0 | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| require github.com/pkg/errors v0.9.1 // indirect | require github.com/pkg/errors v0.9.1 // indirect | ||||||
| 
 | 
 | ||||||
|  | require firebase.google.com/go/v4 v4.8.0 | ||||||
|  | 
 | ||||||
| require ( | require ( | ||||||
| 	cloud.google.com/go v0.102.0 // indirect | 	cloud.google.com/go v0.102.0 // indirect | ||||||
| 	cloud.google.com/go/compute v1.6.1 // indirect | 	cloud.google.com/go/compute v1.6.1 // indirect | ||||||
|  | @ -43,13 +44,14 @@ require ( | ||||||
| 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||||||
| 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect | 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect | ||||||
| 	go.opencensus.io v0.23.0 // indirect | 	go.opencensus.io v0.23.0 // indirect | ||||||
| 	golang.org/x/net v0.0.0-20220526153639-5463443f8c37 // indirect | 	golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect | ||||||
| 	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect | 	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect | ||||||
| 	golang.org/x/text v0.3.7 // indirect | 	golang.org/x/text v0.3.7 // indirect | ||||||
| 	golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect | 	golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect | ||||||
| 	google.golang.org/appengine v1.6.7 // indirect | 	google.golang.org/appengine v1.6.7 // indirect | ||||||
| 	google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58 // indirect | 	google.golang.org/appengine/v2 v2.0.1 // indirect | ||||||
| 	google.golang.org/grpc v1.46.2 // indirect | 	google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect | ||||||
|  | 	google.golang.org/grpc v1.47.0 // indirect | ||||||
| 	google.golang.org/protobuf v1.28.0 // indirect | 	google.golang.org/protobuf v1.28.0 // indirect | ||||||
| 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect | 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect | ||||||
| ) | ) | ||||||
|  |  | ||||||
							
								
								
									
										40
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						
									
										40
									
								
								go.sum
									
										
									
									
									
								
							|  | @ -26,6 +26,7 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y | ||||||
| cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= | cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= | ||||||
| cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= | cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= | ||||||
| cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= | cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= | ||||||
|  | cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= | ||||||
| cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= | cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= | ||||||
| cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8= | cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8= | ||||||
| cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= | cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= | ||||||
|  | @ -36,6 +37,7 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM | ||||||
| cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= | ||||||
| cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= | ||||||
| cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= | cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= | ||||||
|  | cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= | ||||||
| cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= | cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= | ||||||
| cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= | cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= | ||||||
| cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= | cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= | ||||||
|  | @ -45,6 +47,7 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7 | ||||||
| cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= | ||||||
| cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= | cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= | ||||||
| cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= | cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= | ||||||
|  | cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw= | ||||||
| cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= | cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= | ||||||
| cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= | cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= | ||||||
| cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= | ||||||
|  | @ -56,11 +59,12 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo | ||||||
| cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= | ||||||
| cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= | ||||||
| cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= | ||||||
|  | cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= | ||||||
| cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg= | cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg= | ||||||
| cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= | cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= | ||||||
| dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= | ||||||
| firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= | firebase.google.com/go/v4 v4.8.0 h1:ooJqjFEh1G6DQ5+wyb/RAXAgku0E2RzJeH6WauSpWSo= | ||||||
| firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= | firebase.google.com/go/v4 v4.8.0/go.mod h1:y+j6xX7BgBco/XaN+YExIBVm6pzvYutheDV3nprvbWc= | ||||||
| github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= | github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= | ||||||
| github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= | github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= | ||||||
| github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= | github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= | ||||||
|  | @ -337,9 +341,9 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su | ||||||
| golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||||
| golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||||
| golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= | ||||||
| golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= |  | ||||||
| golang.org/x/net v0.0.0-20220526153639-5463443f8c37 h1:lUkvobShwKsOesNfWWlCS5q7fnbG1MEliIzwu886fn8= |  | ||||||
| golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | ||||||
|  | golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA= | ||||||
|  | golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | ||||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||||
| golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||||
| golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||||
|  | @ -373,8 +377,9 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ | ||||||
| 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/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4= |  | ||||||
| golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8= | ||||||
|  | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
| golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
|  | @ -425,9 +430,11 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc | ||||||
| golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
|  | golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
|  | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
|  | @ -543,15 +550,19 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr | ||||||
| google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= | google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= | ||||||
| google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= | google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= | ||||||
| google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= | google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= | ||||||
|  | google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM= | ||||||
|  | google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M= | ||||||
| google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= | google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= | ||||||
|  | google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80= | ||||||
| google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= | google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= | ||||||
| google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= | google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= | ||||||
|  | google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI= | ||||||
| google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= | google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= | ||||||
| google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= | google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= | ||||||
| google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= | google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= | ||||||
| google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= | google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= | ||||||
| google.golang.org/api v0.81.0 h1:o8WF5AvfidafWbFjsRyupxyEQJNUWxLZJCK5NXrxZZ8= | google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw= | ||||||
| google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko= | google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os= | ||||||
| google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||||
| google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||||
| google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||||
|  | @ -560,6 +571,8 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID | ||||||
| google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= | ||||||
| google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= | ||||||
| google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= | ||||||
|  | google.golang.org/appengine/v2 v2.0.1 h1:jTGfiRmR5qoInpT3CXJ72GJEB4owDGEKN+xRDA6ekBY= | ||||||
|  | google.golang.org/appengine/v2 v2.0.1/go.mod h1:XgltgQxPOF3ShivrVrZyfvYCx8Dunh73bKjUuXUZb8Q= | ||||||
| google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= | ||||||
| google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= | ||||||
| google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= | ||||||
|  | @ -623,8 +636,14 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6 | ||||||
| google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
| google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
| google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
|  | google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
|  | google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
|  | google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
| google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
|  | google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
| google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= | ||||||
|  | google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||||
|  | google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||||
| google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||||
| google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||||
| google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= | ||||||
|  | @ -637,10 +656,10 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX | ||||||
| google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= | google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= | ||||||
| google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= | google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= | ||||||
| google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= | google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= | ||||||
| google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= |  | ||||||
| google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= | google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= | ||||||
| google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58 h1:a221mAAEAzq4Lz6ZWRkcS8ptb2mxoxYSt4N68aRyQHM= |  | ||||||
| google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= | google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= | ||||||
|  | google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM= | ||||||
|  | google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= | ||||||
| google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= | ||||||
| google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= | ||||||
| google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= | ||||||
|  | @ -670,8 +689,9 @@ google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K | ||||||
| google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= | google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= | ||||||
| google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= | google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= | ||||||
| google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= | google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= | ||||||
| google.golang.org/grpc v1.46.2 h1:u+MLGgVf7vRdjEYZ8wDFhAVNmhkbJ5hmrA1LMWK1CAQ= |  | ||||||
| google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= | google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= | ||||||
|  | google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8= | ||||||
|  | google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= | ||||||
| google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= | ||||||
| google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= | ||||||
| google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= | ||||||
|  |  | ||||||
							
								
								
									
										129
									
								
								log/log.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								log/log.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,129 @@ | ||||||
|  | package log | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"log" | ||||||
|  | 	"strings" | ||||||
|  | 	"sync" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Level is a well-known log level, as defined below | ||||||
|  | type Level int | ||||||
|  | 
 | ||||||
|  | // Well known log levels | ||||||
|  | const ( | ||||||
|  | 	TraceLevel Level = iota | ||||||
|  | 	DebugLevel | ||||||
|  | 	InfoLevel | ||||||
|  | 	WarnLevel | ||||||
|  | 	ErrorLevel | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func (l Level) String() string { | ||||||
|  | 	switch l { | ||||||
|  | 	case TraceLevel: | ||||||
|  | 		return "TRACE" | ||||||
|  | 	case DebugLevel: | ||||||
|  | 		return "DEBUG" | ||||||
|  | 	case InfoLevel: | ||||||
|  | 		return "INFO" | ||||||
|  | 	case WarnLevel: | ||||||
|  | 		return "WARN" | ||||||
|  | 	case ErrorLevel: | ||||||
|  | 		return "ERROR" | ||||||
|  | 	} | ||||||
|  | 	return "unknown" | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | var ( | ||||||
|  | 	level = InfoLevel | ||||||
|  | 	mu    = &sync.Mutex{} | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Trace prints the given message, if the current log level is TRACE | ||||||
|  | func Trace(message string, v ...interface{}) { | ||||||
|  | 	logIf(TraceLevel, message, v...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Debug prints the given message, if the current log level is DEBUG or lower | ||||||
|  | func Debug(message string, v ...interface{}) { | ||||||
|  | 	logIf(DebugLevel, message, v...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Info prints the given message, if the current log level is INFO or lower | ||||||
|  | func Info(message string, v ...interface{}) { | ||||||
|  | 	logIf(InfoLevel, message, v...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Warn prints the given message, if the current log level is WARN or lower | ||||||
|  | func Warn(message string, v ...interface{}) { | ||||||
|  | 	logIf(WarnLevel, message, v...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Error prints the given message, if the current log level is ERROR or lower | ||||||
|  | func Error(message string, v ...interface{}) { | ||||||
|  | 	logIf(ErrorLevel, message, v...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Fatal prints the given message, and exits the program | ||||||
|  | func Fatal(v ...interface{}) { | ||||||
|  | 	log.Fatalln(v...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CurrentLevel returns the current log level | ||||||
|  | func CurrentLevel() Level { | ||||||
|  | 	mu.Lock() | ||||||
|  | 	defer mu.Unlock() | ||||||
|  | 	return level | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetLevel sets a new log level | ||||||
|  | func SetLevel(newLevel Level) { | ||||||
|  | 	mu.Lock() | ||||||
|  | 	defer mu.Unlock() | ||||||
|  | 	level = newLevel | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // DisableDates disables the date/time prefix | ||||||
|  | func DisableDates() { | ||||||
|  | 	log.SetFlags(0) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ToLevel converts a string to a Level. It returns InfoLevel if the string | ||||||
|  | // does not match any known log levels. | ||||||
|  | func ToLevel(s string) Level { | ||||||
|  | 	switch strings.ToUpper(s) { | ||||||
|  | 	case "TRACE": | ||||||
|  | 		return TraceLevel | ||||||
|  | 	case "DEBUG": | ||||||
|  | 		return DebugLevel | ||||||
|  | 	case "INFO": | ||||||
|  | 		return InfoLevel | ||||||
|  | 	case "WARN", "WARNING": | ||||||
|  | 		return WarnLevel | ||||||
|  | 	case "ERROR": | ||||||
|  | 		return ErrorLevel | ||||||
|  | 	default: | ||||||
|  | 		return InfoLevel | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Loggable returns true if the given log level is lower or equal to the current log level | ||||||
|  | func Loggable(l Level) bool { | ||||||
|  | 	return CurrentLevel() <= l | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // IsTrace returns true if the current log level is TraceLevel | ||||||
|  | func IsTrace() bool { | ||||||
|  | 	return Loggable(TraceLevel) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // IsDebug returns true if the current log level is DebugLevel or below | ||||||
|  | func IsDebug() bool { | ||||||
|  | 	return Loggable(DebugLevel) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func logIf(l Level, message string, v ...interface{}) { | ||||||
|  | 	if CurrentLevel() <= l { | ||||||
|  | 		log.Printf(l.String()+" "+message, v...) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -6,15 +6,16 @@ import ( | ||||||
| 
 | 
 | ||||||
| // Defines default config settings (excluding limits, see below) | // Defines default config settings (excluding limits, see below) | ||||||
| const ( | const ( | ||||||
| 	DefaultListenHTTP                = ":80" | 	DefaultListenHTTP                           = ":80" | ||||||
| 	DefaultCacheDuration             = 12 * time.Hour | 	DefaultCacheDuration                        = 12 * time.Hour | ||||||
| 	DefaultKeepaliveInterval         = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) | 	DefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) | ||||||
| 	DefaultManagerInterval           = time.Minute | 	DefaultManagerInterval                      = time.Minute | ||||||
| 	DefaultAtSenderInterval          = 10 * time.Second | 	DefaultDelayedSenderInterval                = 10 * time.Second | ||||||
| 	DefaultMinDelay                  = 10 * time.Second | 	DefaultMinDelay                             = 10 * time.Second | ||||||
| 	DefaultMaxDelay                  = 3 * 24 * time.Hour | 	DefaultMaxDelay                             = 3 * 24 * time.Hour | ||||||
| 	DefaultFirebaseKeepaliveInterval = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery | 	DefaultFirebaseKeepaliveInterval            = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery | ||||||
| 	DefaultFirebasePollInterval      = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs) | 	DefaultFirebasePollInterval                 = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs) | ||||||
|  | 	DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Defines all global and per-visitor limits | // Defines all global and per-visitor limits | ||||||
|  | @ -66,9 +67,10 @@ type Config struct { | ||||||
| 	KeepaliveInterval                    time.Duration | 	KeepaliveInterval                    time.Duration | ||||||
| 	ManagerInterval                      time.Duration | 	ManagerInterval                      time.Duration | ||||||
| 	WebRootIsApp                         bool | 	WebRootIsApp                         bool | ||||||
| 	AtSenderInterval                     time.Duration | 	DelayedSenderInterval                time.Duration | ||||||
| 	FirebaseKeepaliveInterval            time.Duration | 	FirebaseKeepaliveInterval            time.Duration | ||||||
| 	FirebasePollInterval                 time.Duration | 	FirebasePollInterval                 time.Duration | ||||||
|  | 	FirebaseQuotaExceededPenaltyDuration time.Duration | ||||||
| 	UpstreamBaseURL                      string | 	UpstreamBaseURL                      string | ||||||
| 	SMTPSenderAddr                       string | 	SMTPSenderAddr                       string | ||||||
| 	SMTPSenderUser                       string | 	SMTPSenderUser                       string | ||||||
|  | @ -118,9 +120,10 @@ func NewConfig() *Config { | ||||||
| 		MessageLimit:                         DefaultMessageLengthLimit, | 		MessageLimit:                         DefaultMessageLengthLimit, | ||||||
| 		MinDelay:                             DefaultMinDelay, | 		MinDelay:                             DefaultMinDelay, | ||||||
| 		MaxDelay:                             DefaultMaxDelay, | 		MaxDelay:                             DefaultMaxDelay, | ||||||
| 		AtSenderInterval:                     DefaultAtSenderInterval, | 		DelayedSenderInterval:                DefaultDelayedSenderInterval, | ||||||
| 		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval, | 		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval, | ||||||
| 		FirebasePollInterval:                 DefaultFirebasePollInterval, | 		FirebasePollInterval:                 DefaultFirebasePollInterval, | ||||||
|  | 		FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration, | ||||||
| 		TotalTopicLimit:                      DefaultTotalTopicLimit, | 		TotalTopicLimit:                      DefaultTotalTopicLimit, | ||||||
| 		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit, | 		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit, | ||||||
| 		VisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit, | 		VisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit, | ||||||
|  |  | ||||||
|  | @ -6,8 +6,8 @@ import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	_ "github.com/mattn/go-sqlite3" // SQLite driver | 	_ "github.com/mattn/go-sqlite3" // SQLite driver | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
| 	"log" |  | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| ) | ) | ||||||
|  | @ -36,7 +36,7 @@ const ( | ||||||
| 			attachment_size INT NOT NULL, | 			attachment_size INT NOT NULL, | ||||||
| 			attachment_expires INT NOT NULL, | 			attachment_expires INT NOT NULL, | ||||||
| 			attachment_url TEXT NOT NULL, | 			attachment_url TEXT NOT NULL, | ||||||
| 			attachment_owner TEXT NOT NULL, | 			sender TEXT NOT NULL, | ||||||
| 			encoding TEXT NOT NULL, | 			encoding TEXT NOT NULL, | ||||||
| 			published INT NOT NULL | 			published INT NOT NULL | ||||||
| 		); | 		); | ||||||
|  | @ -45,37 +45,37 @@ const ( | ||||||
| 		COMMIT; | 		COMMIT; | ||||||
| 	` | 	` | ||||||
| 	insertMessageQuery = ` | 	insertMessageQuery = ` | ||||||
| 		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)  | 		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)  | ||||||
| 		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | 		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||||||
| 	` | 	` | ||||||
| 	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ? AND published = 1` | 	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ? AND published = 1` | ||||||
| 	selectRowIDFromMessageID     = `SELECT id FROM messages WHERE topic = ? AND mid = ?` | 	selectRowIDFromMessageID     = `SELECT id FROM messages WHERE topic = ? AND mid = ?` | ||||||
| 	selectMessagesSinceTimeQuery = ` | 	selectMessagesSinceTimeQuery = ` | ||||||
| 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding | 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding | ||||||
| 		FROM messages  | 		FROM messages  | ||||||
| 		WHERE topic = ? AND time >= ? AND published = 1 | 		WHERE topic = ? AND time >= ? AND published = 1 | ||||||
| 		ORDER BY time, id | 		ORDER BY time, id | ||||||
| 	` | 	` | ||||||
| 	selectMessagesSinceTimeIncludeScheduledQuery = ` | 	selectMessagesSinceTimeIncludeScheduledQuery = ` | ||||||
| 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding | 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding | ||||||
| 		FROM messages  | 		FROM messages  | ||||||
| 		WHERE topic = ? AND time >= ? | 		WHERE topic = ? AND time >= ? | ||||||
| 		ORDER BY time, id | 		ORDER BY time, id | ||||||
| 	` | 	` | ||||||
| 	selectMessagesSinceIDQuery = ` | 	selectMessagesSinceIDQuery = ` | ||||||
| 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding | 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding | ||||||
| 		FROM messages  | 		FROM messages  | ||||||
| 		WHERE topic = ? AND id > ? AND published = 1  | 		WHERE topic = ? AND id > ? AND published = 1  | ||||||
| 		ORDER BY time, id | 		ORDER BY time, id | ||||||
| 	` | 	` | ||||||
| 	selectMessagesSinceIDIncludeScheduledQuery = ` | 	selectMessagesSinceIDIncludeScheduledQuery = ` | ||||||
| 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding | 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding | ||||||
| 		FROM messages  | 		FROM messages  | ||||||
| 		WHERE topic = ? AND (id > ? OR published = 0) | 		WHERE topic = ? AND (id > ? OR published = 0) | ||||||
| 		ORDER BY time, id | 		ORDER BY time, id | ||||||
| 	` | 	` | ||||||
| 	selectMessagesDueQuery = ` | 	selectMessagesDueQuery = ` | ||||||
| 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding | 		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding | ||||||
| 		FROM messages  | 		FROM messages  | ||||||
| 		WHERE time <= ? AND published = 0 | 		WHERE time <= ? AND published = 0 | ||||||
| 		ORDER BY time, id | 		ORDER BY time, id | ||||||
|  | @ -84,13 +84,13 @@ const ( | ||||||
| 	selectMessagesCountQuery        = `SELECT COUNT(*) FROM messages` | 	selectMessagesCountQuery        = `SELECT COUNT(*) FROM messages` | ||||||
| 	selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` | 	selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` | ||||||
| 	selectTopicsQuery               = `SELECT topic FROM messages GROUP BY topic` | 	selectTopicsQuery               = `SELECT topic FROM messages GROUP BY topic` | ||||||
| 	selectAttachmentsSizeQuery      = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?` | 	selectAttachmentsSizeQuery      = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?` | ||||||
| 	selectAttachmentsExpiredQuery   = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` | 	selectAttachmentsExpiredQuery   = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Schema management queries | // Schema management queries | ||||||
| const ( | const ( | ||||||
| 	currentSchemaVersion          = 6 | 	currentSchemaVersion          = 7 | ||||||
| 	createSchemaVersionTableQuery = ` | 	createSchemaVersionTableQuery = ` | ||||||
| 		CREATE TABLE IF NOT EXISTS schemaVersion ( | 		CREATE TABLE IF NOT EXISTS schemaVersion ( | ||||||
| 			id INT PRIMARY KEY, | 			id INT PRIMARY KEY, | ||||||
|  | @ -173,6 +173,11 @@ const ( | ||||||
| 	migrate5To6AlterMessagesTableQuery = ` | 	migrate5To6AlterMessagesTableQuery = ` | ||||||
| 		ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT(''); | 		ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT(''); | ||||||
| 	` | 	` | ||||||
|  | 
 | ||||||
|  | 	// 6 -> 7 | ||||||
|  | 	migrate6To7AlterMessagesTableQuery = ` | ||||||
|  | 		ALTER TABLE messages RENAME COLUMN attachment_owner TO sender; | ||||||
|  | 	` | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type messageCache struct { | type messageCache struct { | ||||||
|  | @ -225,7 +230,7 @@ func (c *messageCache) AddMessage(m *message) error { | ||||||
| 	} | 	} | ||||||
| 	published := m.Time <= time.Now().Unix() | 	published := m.Time <= time.Now().Unix() | ||||||
| 	tags := strings.Join(m.Tags, ",") | 	tags := strings.Join(m.Tags, ",") | ||||||
| 	var attachmentName, attachmentType, attachmentURL, attachmentOwner string | 	var attachmentName, attachmentType, attachmentURL string | ||||||
| 	var attachmentSize, attachmentExpires int64 | 	var attachmentSize, attachmentExpires int64 | ||||||
| 	if m.Attachment != nil { | 	if m.Attachment != nil { | ||||||
| 		attachmentName = m.Attachment.Name | 		attachmentName = m.Attachment.Name | ||||||
|  | @ -233,7 +238,6 @@ func (c *messageCache) AddMessage(m *message) error { | ||||||
| 		attachmentSize = m.Attachment.Size | 		attachmentSize = m.Attachment.Size | ||||||
| 		attachmentExpires = m.Attachment.Expires | 		attachmentExpires = m.Attachment.Expires | ||||||
| 		attachmentURL = m.Attachment.URL | 		attachmentURL = m.Attachment.URL | ||||||
| 		attachmentOwner = m.Attachment.Owner |  | ||||||
| 	} | 	} | ||||||
| 	var actionsStr string | 	var actionsStr string | ||||||
| 	if len(m.Actions) > 0 { | 	if len(m.Actions) > 0 { | ||||||
|  | @ -259,7 +263,7 @@ func (c *messageCache) AddMessage(m *message) error { | ||||||
| 		attachmentSize, | 		attachmentSize, | ||||||
| 		attachmentExpires, | 		attachmentExpires, | ||||||
| 		attachmentURL, | 		attachmentURL, | ||||||
| 		attachmentOwner, | 		m.Sender, | ||||||
| 		m.Encoding, | 		m.Encoding, | ||||||
| 		published, | 		published, | ||||||
| 	) | 	) | ||||||
|  | @ -371,8 +375,8 @@ func (c *messageCache) Prune(olderThan time.Time) error { | ||||||
| 	return err | 	return err | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) { | func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) { | ||||||
| 	rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix()) | 	rows, err := c.db.Query(selectAttachmentsSizeQuery, sender, time.Now().Unix()) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return 0, err | 		return 0, err | ||||||
| 	} | 	} | ||||||
|  | @ -415,7 +419,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { | ||||||
| 	for rows.Next() { | 	for rows.Next() { | ||||||
| 		var timestamp, attachmentSize, attachmentExpires int64 | 		var timestamp, attachmentSize, attachmentExpires int64 | ||||||
| 		var priority int | 		var priority int | ||||||
| 		var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string | 		var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string | ||||||
| 		err := rows.Scan( | 		err := rows.Scan( | ||||||
| 			&id, | 			&id, | ||||||
| 			×tamp, | 			×tamp, | ||||||
|  | @ -431,7 +435,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { | ||||||
| 			&attachmentSize, | 			&attachmentSize, | ||||||
| 			&attachmentExpires, | 			&attachmentExpires, | ||||||
| 			&attachmentURL, | 			&attachmentURL, | ||||||
| 			&attachmentOwner, | 			&sender, | ||||||
| 			&encoding, | 			&encoding, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -455,7 +459,6 @@ func readMessages(rows *sql.Rows) ([]*message, error) { | ||||||
| 				Size:    attachmentSize, | 				Size:    attachmentSize, | ||||||
| 				Expires: attachmentExpires, | 				Expires: attachmentExpires, | ||||||
| 				URL:     attachmentURL, | 				URL:     attachmentURL, | ||||||
| 				Owner:   attachmentOwner, |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		messages = append(messages, &message{ | 		messages = append(messages, &message{ | ||||||
|  | @ -470,6 +473,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { | ||||||
| 			Click:      click, | 			Click:      click, | ||||||
| 			Actions:    actions, | 			Actions:    actions, | ||||||
| 			Attachment: att, | 			Attachment: att, | ||||||
|  | 			Sender:     sender, | ||||||
| 			Encoding:   encoding, | 			Encoding:   encoding, | ||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
|  | @ -516,6 +520,8 @@ func setupCacheDB(db *sql.DB) error { | ||||||
| 		return migrateFrom4(db) | 		return migrateFrom4(db) | ||||||
| 	} else if schemaVersion == 5 { | 	} else if schemaVersion == 5 { | ||||||
| 		return migrateFrom5(db) | 		return migrateFrom5(db) | ||||||
|  | 	} else if schemaVersion == 6 { | ||||||
|  | 		return migrateFrom6(db) | ||||||
| 	} | 	} | ||||||
| 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion) | 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion) | ||||||
| } | } | ||||||
|  | @ -534,7 +540,7 @@ func setupNewCacheDB(db *sql.DB) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func migrateFrom0(db *sql.DB) error { | func migrateFrom0(db *sql.DB) error { | ||||||
| 	log.Print("Migrating cache database schema: from 0 to 1") | 	log.Info("Migrating cache database schema: from 0 to 1") | ||||||
| 	if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { | 	if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -548,7 +554,7 @@ func migrateFrom0(db *sql.DB) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func migrateFrom1(db *sql.DB) error { | func migrateFrom1(db *sql.DB) error { | ||||||
| 	log.Print("Migrating cache database schema: from 1 to 2") | 	log.Info("Migrating cache database schema: from 1 to 2") | ||||||
| 	if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil { | 	if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -559,7 +565,7 @@ func migrateFrom1(db *sql.DB) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func migrateFrom2(db *sql.DB) error { | func migrateFrom2(db *sql.DB) error { | ||||||
| 	log.Print("Migrating cache database schema: from 2 to 3") | 	log.Info("Migrating cache database schema: from 2 to 3") | ||||||
| 	if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { | 	if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -570,7 +576,7 @@ func migrateFrom2(db *sql.DB) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func migrateFrom3(db *sql.DB) error { | func migrateFrom3(db *sql.DB) error { | ||||||
| 	log.Print("Migrating cache database schema: from 3 to 4") | 	log.Info("Migrating cache database schema: from 3 to 4") | ||||||
| 	if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil { | 	if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -581,7 +587,7 @@ func migrateFrom3(db *sql.DB) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func migrateFrom4(db *sql.DB) error { | func migrateFrom4(db *sql.DB) error { | ||||||
| 	log.Print("Migrating cache database schema: from 4 to 5") | 	log.Info("Migrating cache database schema: from 4 to 5") | ||||||
| 	if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil { | 	if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -592,12 +598,23 @@ func migrateFrom4(db *sql.DB) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func migrateFrom5(db *sql.DB) error { | func migrateFrom5(db *sql.DB) error { | ||||||
| 	log.Print("Migrating cache database schema: from 5 to 6") | 	log.Info("Migrating cache database schema: from 5 to 6") | ||||||
| 	if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil { | 	if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	if _, err := db.Exec(updateSchemaVersion, 6); err != nil { | 	if _, err := db.Exec(updateSchemaVersion, 6); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | 	return migrateFrom6(db) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func migrateFrom6(db *sql.DB) error { | ||||||
|  | 	log.Info("Migrating cache database schema: from 6 to 7") | ||||||
|  | 	if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if _, err := db.Exec(updateSchemaVersion, 7); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
| 	return nil // Update this when a new version is added | 	return nil // Update this when a new version is added | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -281,39 +281,39 @@ func testCacheAttachments(t *testing.T, c *messageCache) { | ||||||
| 	expires1 := time.Now().Add(-4 * time.Hour).Unix() | 	expires1 := time.Now().Add(-4 * time.Hour).Unix() | ||||||
| 	m := newDefaultMessage("mytopic", "flower for you") | 	m := newDefaultMessage("mytopic", "flower for you") | ||||||
| 	m.ID = "m1" | 	m.ID = "m1" | ||||||
|  | 	m.Sender = "1.2.3.4" | ||||||
| 	m.Attachment = &attachment{ | 	m.Attachment = &attachment{ | ||||||
| 		Name:    "flower.jpg", | 		Name:    "flower.jpg", | ||||||
| 		Type:    "image/jpeg", | 		Type:    "image/jpeg", | ||||||
| 		Size:    5000, | 		Size:    5000, | ||||||
| 		Expires: expires1, | 		Expires: expires1, | ||||||
| 		URL:     "https://ntfy.sh/file/AbDeFgJhal.jpg", | 		URL:     "https://ntfy.sh/file/AbDeFgJhal.jpg", | ||||||
| 		Owner:   "1.2.3.4", |  | ||||||
| 	} | 	} | ||||||
| 	require.Nil(t, c.AddMessage(m)) | 	require.Nil(t, c.AddMessage(m)) | ||||||
| 
 | 
 | ||||||
| 	expires2 := time.Now().Add(2 * time.Hour).Unix() // Future | 	expires2 := time.Now().Add(2 * time.Hour).Unix() // Future | ||||||
| 	m = newDefaultMessage("mytopic", "sending you a car") | 	m = newDefaultMessage("mytopic", "sending you a car") | ||||||
| 	m.ID = "m2" | 	m.ID = "m2" | ||||||
|  | 	m.Sender = "1.2.3.4" | ||||||
| 	m.Attachment = &attachment{ | 	m.Attachment = &attachment{ | ||||||
| 		Name:    "car.jpg", | 		Name:    "car.jpg", | ||||||
| 		Type:    "image/jpeg", | 		Type:    "image/jpeg", | ||||||
| 		Size:    10000, | 		Size:    10000, | ||||||
| 		Expires: expires2, | 		Expires: expires2, | ||||||
| 		URL:     "https://ntfy.sh/file/aCaRURL.jpg", | 		URL:     "https://ntfy.sh/file/aCaRURL.jpg", | ||||||
| 		Owner:   "1.2.3.4", |  | ||||||
| 	} | 	} | ||||||
| 	require.Nil(t, c.AddMessage(m)) | 	require.Nil(t, c.AddMessage(m)) | ||||||
| 
 | 
 | ||||||
| 	expires3 := time.Now().Add(1 * time.Hour).Unix() // Future | 	expires3 := time.Now().Add(1 * time.Hour).Unix() // Future | ||||||
| 	m = newDefaultMessage("another-topic", "sending you another car") | 	m = newDefaultMessage("another-topic", "sending you another car") | ||||||
| 	m.ID = "m3" | 	m.ID = "m3" | ||||||
|  | 	m.Sender = "1.2.3.4" | ||||||
| 	m.Attachment = &attachment{ | 	m.Attachment = &attachment{ | ||||||
| 		Name:    "another-car.jpg", | 		Name:    "another-car.jpg", | ||||||
| 		Type:    "image/jpeg", | 		Type:    "image/jpeg", | ||||||
| 		Size:    20000, | 		Size:    20000, | ||||||
| 		Expires: expires3, | 		Expires: expires3, | ||||||
| 		URL:     "https://ntfy.sh/file/zakaDHFW.jpg", | 		URL:     "https://ntfy.sh/file/zakaDHFW.jpg", | ||||||
| 		Owner:   "1.2.3.4", |  | ||||||
| 	} | 	} | ||||||
| 	require.Nil(t, c.AddMessage(m)) | 	require.Nil(t, c.AddMessage(m)) | ||||||
| 
 | 
 | ||||||
|  | @ -327,7 +327,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { | ||||||
| 	require.Equal(t, int64(5000), messages[0].Attachment.Size) | 	require.Equal(t, int64(5000), messages[0].Attachment.Size) | ||||||
| 	require.Equal(t, expires1, messages[0].Attachment.Expires) | 	require.Equal(t, expires1, messages[0].Attachment.Expires) | ||||||
| 	require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) | 	require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) | ||||||
| 	require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner) | 	require.Equal(t, "1.2.3.4", messages[0].Sender) | ||||||
| 
 | 
 | ||||||
| 	require.Equal(t, "sending you a car", messages[1].Message) | 	require.Equal(t, "sending you a car", messages[1].Message) | ||||||
| 	require.Equal(t, "car.jpg", messages[1].Attachment.Name) | 	require.Equal(t, "car.jpg", messages[1].Attachment.Name) | ||||||
|  | @ -335,7 +335,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { | ||||||
| 	require.Equal(t, int64(10000), messages[1].Attachment.Size) | 	require.Equal(t, int64(10000), messages[1].Attachment.Size) | ||||||
| 	require.Equal(t, expires2, messages[1].Attachment.Expires) | 	require.Equal(t, expires2, messages[1].Attachment.Expires) | ||||||
| 	require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) | 	require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) | ||||||
| 	require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner) | 	require.Equal(t, "1.2.3.4", messages[1].Sender) | ||||||
| 
 | 
 | ||||||
| 	size, err := c.AttachmentBytesUsed("1.2.3.4") | 	size, err := c.AttachmentBytesUsed("1.2.3.4") | ||||||
| 	require.Nil(t, err) | 	require.Nil(t, err) | ||||||
|  |  | ||||||
|  | @ -5,7 +5,8 @@ After=network.target | ||||||
| [Service] | [Service] | ||||||
| User=ntfy | User=ntfy | ||||||
| Group=ntfy | Group=ntfy | ||||||
| ExecStart=/usr/bin/ntfy serve | ExecStart=/usr/bin/ntfy serve --no-log-dates | ||||||
|  | ExecReload=/bin/kill --signal HUP $MAINPID | ||||||
| Restart=on-failure | Restart=on-failure | ||||||
| AmbientCapabilities=CAP_NET_BIND_SERVICE | AmbientCapabilities=CAP_NET_BIND_SERVICE | ||||||
| LimitNOFILE=10000 | LimitNOFILE=10000 | ||||||
|  |  | ||||||
							
								
								
									
										315
									
								
								server/server.go
									
										
									
									
									
								
							
							
						
						
									
										315
									
								
								server/server.go
									
										
									
									
									
								
							|  | @ -7,13 +7,11 @@ import ( | ||||||
| 	"embed" | 	"embed" | ||||||
| 	"encoding/base64" | 	"encoding/base64" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"io" | 	"io" | ||||||
| 	"log" |  | ||||||
| 	"net" | 	"net" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" |  | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"os" | 	"os" | ||||||
| 	"path" | 	"path" | ||||||
|  | @ -34,22 +32,22 @@ import ( | ||||||
| 
 | 
 | ||||||
| // Server is the main server, providing the UI and API for ntfy | // Server is the main server, providing the UI and API for ntfy | ||||||
| type Server struct { | type Server struct { | ||||||
| 	config       *Config | 	config            *Config | ||||||
| 	httpServer   *http.Server | 	httpServer        *http.Server | ||||||
| 	httpsServer  *http.Server | 	httpsServer       *http.Server | ||||||
| 	unixListener net.Listener | 	unixListener      net.Listener | ||||||
| 	smtpServer   *smtp.Server | 	smtpServer        *smtp.Server | ||||||
| 	smtpBackend  *smtpBackend | 	smtpServerBackend *smtpBackend | ||||||
| 	topics       map[string]*topic | 	smtpSender        mailer | ||||||
| 	visitors     map[string]*visitor | 	topics            map[string]*topic | ||||||
| 	firebase     subscriber | 	visitors          map[string]*visitor | ||||||
| 	mailer       mailer | 	firebaseClient    *firebaseClient | ||||||
| 	messages     int64 | 	messages          int64 | ||||||
| 	auth         auth.Auther | 	auth              auth.Auther | ||||||
| 	messageCache *messageCache | 	messageCache      *messageCache | ||||||
| 	fileCache    *fileCache | 	fileCache         *fileCache | ||||||
| 	closeChan    chan bool | 	closeChan         chan bool | ||||||
| 	mu           sync.Mutex | 	mu                sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // handleFunc extends the normal http.HandlerFunc to be able to easily return errors | // handleFunc extends the normal http.HandlerFunc to be able to easily return errors | ||||||
|  | @ -136,23 +134,23 @@ func New(conf *Config) (*Server, error) { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	var firebaseSubscriber subscriber | 	var firebaseClient *firebaseClient | ||||||
| 	if conf.FirebaseKeyFile != "" { | 	if conf.FirebaseKeyFile != "" { | ||||||
| 		var err error | 		sender, err := newFirebaseSender(conf.FirebaseKeyFile) | ||||||
| 		firebaseSubscriber, err = createFirebaseSubscriber(conf.FirebaseKeyFile, auther) |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
|  | 		firebaseClient = newFirebaseClient(sender, auther) | ||||||
| 	} | 	} | ||||||
| 	return &Server{ | 	return &Server{ | ||||||
| 		config:       conf, | 		config:         conf, | ||||||
| 		messageCache: messageCache, | 		messageCache:   messageCache, | ||||||
| 		fileCache:    fileCache, | 		fileCache:      fileCache, | ||||||
| 		firebase:     firebaseSubscriber, | 		firebaseClient: firebaseClient, | ||||||
| 		mailer:       mailer, | 		smtpSender:     mailer, | ||||||
| 		topics:       topics, | 		topics:         topics, | ||||||
| 		auth:         auther, | 		auth:           auther, | ||||||
| 		visitors:     make(map[string]*visitor), | 		visitors:       make(map[string]*visitor), | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -181,7 +179,7 @@ func (s *Server) Run() error { | ||||||
| 	if s.config.SMTPServerListen != "" { | 	if s.config.SMTPServerListen != "" { | ||||||
| 		listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) | 		listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) | ||||||
| 	} | 	} | ||||||
| 	log.Printf("Listening on%s", listenStr) | 	log.Info("Listening on%s, log level is %s", listenStr, log.CurrentLevel().String()) | ||||||
| 	mux := http.NewServeMux() | 	mux := http.NewServeMux() | ||||||
| 	mux.HandleFunc("/", s.handle) | 	mux.HandleFunc("/", s.handle) | ||||||
| 	errChan := make(chan error) | 	errChan := make(chan error) | ||||||
|  | @ -221,7 +219,7 @@ func (s *Server) Run() error { | ||||||
| 	} | 	} | ||||||
| 	s.mu.Unlock() | 	s.mu.Unlock() | ||||||
| 	go s.runManager() | 	go s.runManager() | ||||||
| 	go s.runAtSender() | 	go s.runDelayedSender() | ||||||
| 	go s.runFirebaseKeepaliver() | 	go s.runFirebaseKeepaliver() | ||||||
| 
 | 
 | ||||||
| 	return <-errChan | 	return <-errChan | ||||||
|  | @ -248,16 +246,27 @@ func (s *Server) Stop() { | ||||||
| 
 | 
 | ||||||
| func (s *Server) handle(w http.ResponseWriter, r *http.Request) { | func (s *Server) handle(w http.ResponseWriter, r *http.Request) { | ||||||
| 	v := s.visitor(r) | 	v := s.visitor(r) | ||||||
|  | 	log.Debug("%s Dispatching request", logHTTPPrefix(v, r)) | ||||||
| 	if err := s.handleInternal(w, r, v); err != nil { | 	if err := s.handleInternal(w, r, v); err != nil { | ||||||
| 		if websocket.IsWebSocketUpgrade(r) { | 		if websocket.IsWebSocketUpgrade(r) { | ||||||
| 			log.Printf("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error()) | 			isNormalError := strings.Contains(err.Error(), "i/o timeout") | ||||||
|  | 			if isNormalError { | ||||||
|  | 				log.Debug("%s WebSocket error (this error is okay, it happens a lot): %s", logHTTPPrefix(v, r), err.Error()) | ||||||
|  | 			} else { | ||||||
|  | 				log.Info("%s WebSocket error: %s", logHTTPPrefix(v, r), err.Error()) | ||||||
|  | 			} | ||||||
| 			return // Do not attempt to write to upgraded connection | 			return // Do not attempt to write to upgraded connection | ||||||
| 		} | 		} | ||||||
| 		httpErr, ok := err.(*errHTTP) | 		httpErr, ok := err.(*errHTTP) | ||||||
| 		if !ok { | 		if !ok { | ||||||
| 			httpErr = errHTTPInternalError | 			httpErr = errHTTPInternalError | ||||||
| 		} | 		} | ||||||
| 		log.Printf("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error()) | 		isNormalError := httpErr.HTTPCode == http.StatusNotFound || httpErr.HTTPCode == http.StatusBadRequest | ||||||
|  | 		if isNormalError { | ||||||
|  | 			log.Debug("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error()) | ||||||
|  | 		} else { | ||||||
|  | 			log.Info("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error()) | ||||||
|  | 		} | ||||||
| 		w.Header().Set("Content-Type", "application/json") | 		w.Header().Set("Content-Type", "application/json") | ||||||
| 		w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests | 		w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests | ||||||
| 		w.WriteHeader(httpErr.HTTPCode) | 		w.WriteHeader(httpErr.HTTPCode) | ||||||
|  | @ -434,19 +443,26 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito | ||||||
| 		m.Message = emptyMessageBody | 		m.Message = emptyMessageBody | ||||||
| 	} | 	} | ||||||
| 	delayed := m.Time > time.Now().Unix() | 	delayed := m.Time > time.Now().Unix() | ||||||
|  | 	log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s", | ||||||
|  | 		logMessagePrefix(v, m), m.Event, len(m.Message), delayed, firebase, cache, unifiedpush, email) | ||||||
|  | 	if log.IsTrace() { | ||||||
|  | 		log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m)) | ||||||
|  | 	} | ||||||
| 	if !delayed { | 	if !delayed { | ||||||
| 		if err := t.Publish(m); err != nil { | 		if err := t.Publish(v, m); err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 	} | 		if s.firebaseClient != nil && firebase { | ||||||
| 	if s.firebase != nil && firebase && !delayed { | 			go s.sendToFirebase(v, m) | ||||||
| 		go s.sendToFirebase(v, m) | 		} | ||||||
| 	} | 		if s.smtpSender != nil && email != "" { | ||||||
| 	if s.mailer != nil && email != "" && !delayed { | 			go s.sendEmail(v, m, email) | ||||||
| 		go s.sendEmail(v, m, email) | 		} | ||||||
| 	} | 		if s.config.UpstreamBaseURL != "" { | ||||||
| 	if s.config.UpstreamBaseURL != "" { | 			go s.forwardPollRequest(v, m) | ||||||
| 		go s.forwardPollRequest(v, m) | 		} | ||||||
|  | 	} else { | ||||||
|  | 		log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m)) | ||||||
| 	} | 	} | ||||||
| 	if cache { | 	if cache { | ||||||
| 		if err := s.messageCache.AddMessage(m); err != nil { | 		if err := s.messageCache.AddMessage(m); err != nil { | ||||||
|  | @ -465,14 +481,20 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) sendToFirebase(v *visitor, m *message) { | func (s *Server) sendToFirebase(v *visitor, m *message) { | ||||||
| 	if err := s.firebase(m); err != nil { | 	log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m)) | ||||||
| 		log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error()) | 	if err := s.firebaseClient.Send(v, m); err != nil { | ||||||
|  | 		if err == errFirebaseTemporarilyBanned { | ||||||
|  | 			log.Debug("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error()) | ||||||
|  | 		} else { | ||||||
|  | 			log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error()) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) sendEmail(v *visitor, m *message, email string) { | func (s *Server) sendEmail(v *visitor, m *message, email string) { | ||||||
| 	if err := s.mailer.Send(v.ip, email, m); err != nil { | 	log.Debug("%s Sending email to %s", logMessagePrefix(v, m), email) | ||||||
| 		log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error()) | 	if err := s.smtpSender.Send(v, m, email); err != nil { | ||||||
|  | 		log.Warn("%s Unable to send email to %s: %v", logMessagePrefix(v, m), email, err.Error()) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -480,18 +502,22 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { | ||||||
| 	topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic) | 	topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic) | ||||||
| 	topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL))) | 	topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL))) | ||||||
| 	forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash) | 	forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash) | ||||||
|  | 	log.Debug("%s Publishing poll request to %s", logMessagePrefix(v, m), forwardURL) | ||||||
| 	req, err := http.NewRequest("POST", forwardURL, strings.NewReader("")) | 	req, err := http.NewRequest("POST", forwardURL, strings.NewReader("")) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error()) | 		log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error()) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	req.Header.Set("X-Poll-ID", m.ID) | 	req.Header.Set("X-Poll-ID", m.ID) | ||||||
| 	response, err := http.DefaultClient.Do(req) | 	var httpClient = &http.Client{ | ||||||
|  | 		Timeout: time.Second * 10, | ||||||
|  | 	} | ||||||
|  | 	response, err := httpClient.Do(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error()) | 		log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error()) | ||||||
| 		return | 		return | ||||||
| 	} else if response.StatusCode != http.StatusOK { | 	} else if response.StatusCode != http.StatusOK { | ||||||
| 		log.Printf("[%s] FWD - Unable to forward poll request, unexpected status: %d", v.ip, response.StatusCode) | 		log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logMessagePrefix(v, m), response.StatusCode) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | @ -533,7 +559,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca | ||||||
| 			return false, false, "", false, errHTTPTooManyRequestsLimitEmails | 			return false, false, "", false, errHTTPTooManyRequestsLimitEmails | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if s.mailer == nil && email != "" { | 	if s.smtpSender == nil && email != "" { | ||||||
| 		return false, false, "", false, errHTTPBadRequestEmailDisabled | 		return false, false, "", false, errHTTPBadRequestEmailDisabled | ||||||
| 	} | 	} | ||||||
| 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") | 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") | ||||||
|  | @ -568,6 +594,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca | ||||||
| 			return false, false, "", false, errHTTPBadRequestDelayTooLarge | 			return false, false, "", false, errHTTPBadRequestDelayTooLarge | ||||||
| 		} | 		} | ||||||
| 		m.Time = delay.Unix() | 		m.Time = delay.Unix() | ||||||
|  | 		m.Sender = v.ip // Important for rate limiting | ||||||
| 	} | 	} | ||||||
| 	actionsStr := readParam(r, "x-actions", "actions", "action") | 	actionsStr := readParam(r, "x-actions", "actions", "action") | ||||||
| 	if actionsStr != "" { | 	if actionsStr != "" { | ||||||
|  | @ -606,7 +633,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca | ||||||
| //    If file.txt is > message limit, treat it as an attachment | //    If file.txt is > message limit, treat it as an attachment | ||||||
| func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error { | func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error { | ||||||
| 	if m.Event == pollRequestEvent { // Case 1 | 	if m.Event == pollRequestEvent { // Case 1 | ||||||
| 		return nil | 		return s.handleBodyDiscard(body) | ||||||
| 	} else if unifiedpush { | 	} else if unifiedpush { | ||||||
| 		return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 | 		return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 | ||||||
| 	} else if m.Attachment != nil && m.Attachment.URL != "" { | 	} else if m.Attachment != nil && m.Attachment.URL != "" { | ||||||
|  | @ -619,6 +646,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body | ||||||
| 	return s.handleBodyAsAttachment(r, v, m, body) // Case 6 | 	return s.handleBodyAsAttachment(r, v, m, body) // Case 6 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error { | ||||||
|  | 	_, err := io.Copy(io.Discard, body) | ||||||
|  | 	_ = body.Close() | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error { | func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error { | ||||||
| 	if utf8.Valid(body.PeekedBytes) { | 	if utf8.Valid(body.PeekedBytes) { | ||||||
| 		m.Message = string(body.PeekedBytes) // Do not trim | 		m.Message = string(body.PeekedBytes) // Do not trim | ||||||
|  | @ -663,7 +696,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, | ||||||
| 		m.Attachment = &attachment{} | 		m.Attachment = &attachment{} | ||||||
| 	} | 	} | ||||||
| 	var ext string | 	var ext string | ||||||
| 	m.Attachment.Owner = v.ip // Important for attachment rate limiting | 	m.Sender = v.ip // Important for attachment rate limiting | ||||||
| 	m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() | 	m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() | ||||||
| 	m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name) | 	m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name) | ||||||
| 	m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) | 	m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) | ||||||
|  | @ -718,6 +751,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error { | func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error { | ||||||
|  | 	log.Debug("%s HTTP stream connection opened", logHTTPPrefix(v, r)) | ||||||
|  | 	defer log.Debug("%s HTTP stream connection closed", logHTTPPrefix(v, r)) | ||||||
| 	if err := v.SubscriptionAllowed(); err != nil { | 	if err := v.SubscriptionAllowed(); err != nil { | ||||||
| 		return errHTTPTooManyRequestsLimitSubscriptions | 		return errHTTPTooManyRequestsLimitSubscriptions | ||||||
| 	} | 	} | ||||||
|  | @ -731,7 +766,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	var wlock sync.Mutex | 	var wlock sync.Mutex | ||||||
| 	sub := func(msg *message) error { | 	sub := func(v *visitor, msg *message) error { | ||||||
| 		if !filters.Pass(msg) { | 		if !filters.Pass(msg) { | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
|  | @ -752,7 +787,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*")            // CORS, allow cross-origin requests | 	w.Header().Set("Access-Control-Allow-Origin", "*")            // CORS, allow cross-origin requests | ||||||
| 	w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset! | 	w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset! | ||||||
| 	if poll { | 	if poll { | ||||||
| 		return s.sendOldMessages(topics, since, scheduled, sub) | 		return s.sendOldMessages(topics, since, scheduled, v, sub) | ||||||
| 	} | 	} | ||||||
| 	subscriberIDs := make([]int, 0) | 	subscriberIDs := make([]int, 0) | ||||||
| 	for _, t := range topics { | 	for _, t := range topics { | ||||||
|  | @ -763,10 +798,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * | ||||||
| 			topics[i].Unsubscribe(subscriberID) // Order! | 			topics[i].Unsubscribe(subscriberID) // Order! | ||||||
| 		} | 		} | ||||||
| 	}() | 	}() | ||||||
| 	if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message | 	if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil { | 	if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	for { | 	for { | ||||||
|  | @ -774,8 +809,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * | ||||||
| 		case <-r.Context().Done(): | 		case <-r.Context().Done(): | ||||||
| 			return nil | 			return nil | ||||||
| 		case <-time.After(s.config.KeepaliveInterval): | 		case <-time.After(s.config.KeepaliveInterval): | ||||||
|  | 			log.Trace("%s Sending keepalive message", logHTTPPrefix(v, r)) | ||||||
| 			v.Keepalive() | 			v.Keepalive() | ||||||
| 			if err := sub(newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message | 			if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message | ||||||
| 				return err | 				return err | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -790,6 +826,8 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi | ||||||
| 		return errHTTPTooManyRequestsLimitSubscriptions | 		return errHTTPTooManyRequestsLimitSubscriptions | ||||||
| 	} | 	} | ||||||
| 	defer v.RemoveSubscription() | 	defer v.RemoveSubscription() | ||||||
|  | 	log.Debug("%s WebSocket connection opened", logHTTPPrefix(v, r)) | ||||||
|  | 	defer log.Debug("%s WebSocket connection closed", logHTTPPrefix(v, r)) | ||||||
| 	topics, topicsStr, err := s.topicsFromPath(r.URL.Path) | 	topics, topicsStr, err := s.topicsFromPath(r.URL.Path) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
|  | @ -819,6 +857,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		conn.SetPongHandler(func(appData string) error { | 		conn.SetPongHandler(func(appData string) error { | ||||||
|  | 			log.Trace("%s Received WebSocket pong", logHTTPPrefix(v, r)) | ||||||
| 			return conn.SetReadDeadline(time.Now().Add(pongWait)) | 			return conn.SetReadDeadline(time.Now().Add(pongWait)) | ||||||
| 		}) | 		}) | ||||||
| 		for { | 		for { | ||||||
|  | @ -835,6 +874,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi | ||||||
| 			if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil { | 			if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil { | ||||||
| 				return err | 				return err | ||||||
| 			} | 			} | ||||||
|  | 			log.Trace("%s Sending WebSocket ping", logHTTPPrefix(v, r)) | ||||||
| 			return conn.WriteMessage(websocket.PingMessage, nil) | 			return conn.WriteMessage(websocket.PingMessage, nil) | ||||||
| 		} | 		} | ||||||
| 		for { | 		for { | ||||||
|  | @ -849,7 +889,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	}) | 	}) | ||||||
| 	sub := func(msg *message) error { | 	sub := func(v *visitor, msg *message) error { | ||||||
| 		if !filters.Pass(msg) { | 		if !filters.Pass(msg) { | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
|  | @ -862,7 +902,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi | ||||||
| 	} | 	} | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests | 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests | ||||||
| 	if poll { | 	if poll { | ||||||
| 		return s.sendOldMessages(topics, since, scheduled, sub) | 		return s.sendOldMessages(topics, since, scheduled, v, sub) | ||||||
| 	} | 	} | ||||||
| 	subscriberIDs := make([]int, 0) | 	subscriberIDs := make([]int, 0) | ||||||
| 	for _, t := range topics { | 	for _, t := range topics { | ||||||
|  | @ -873,15 +913,16 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi | ||||||
| 			topics[i].Unsubscribe(subscriberID) // Order! | 			topics[i].Unsubscribe(subscriberID) // Order! | ||||||
| 		} | 		} | ||||||
| 	}() | 	}() | ||||||
| 	if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message | 	if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil { | 	if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	err = g.Wait() | 	err = g.Wait() | ||||||
| 	if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { | 	if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { | ||||||
| 		return nil // Normal closures are not errors | 		log.Trace("%s WebSocket connection closed: %s", logHTTPPrefix(v, r), err.Error()) | ||||||
|  | 		return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot | ||||||
| 	} | 	} | ||||||
| 	return err | 	return err | ||||||
| } | } | ||||||
|  | @ -900,7 +941,7 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu | ||||||
| 	return | 	return | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, sub subscriber) error { | func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error { | ||||||
| 	if since.IsNone() { | 	if since.IsNone() { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
|  | @ -910,7 +951,7 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		for _, m := range messages { | 		for _, m := range messages { | ||||||
| 			if err := sub(m); err != nil { | 			if err := sub(v, m); err != nil { | ||||||
| 				return err | 				return err | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -1004,28 +1045,36 @@ func (s *Server) updateStatsAndPrune() { | ||||||
| 	defer s.mu.Unlock() | 	defer s.mu.Unlock() | ||||||
| 
 | 
 | ||||||
| 	// Expire visitors from rate visitors map | 	// Expire visitors from rate visitors map | ||||||
|  | 	staleVisitors := 0 | ||||||
| 	for ip, v := range s.visitors { | 	for ip, v := range s.visitors { | ||||||
| 		if v.Stale() { | 		if v.Stale() { | ||||||
|  | 			log.Debug("Deleting stale visitor %s", v.ip) | ||||||
| 			delete(s.visitors, ip) | 			delete(s.visitors, ip) | ||||||
|  | 			staleVisitors++ | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 	log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors) | ||||||
| 
 | 
 | ||||||
| 	// Delete expired attachments | 	// Delete expired attachments | ||||||
| 	if s.fileCache != nil { | 	if s.fileCache != nil { | ||||||
| 		ids, err := s.messageCache.AttachmentsExpired() | 		ids, err := s.messageCache.AttachmentsExpired() | ||||||
| 		if err == nil { | 		if err != nil { | ||||||
|  | 			log.Warn("Error retrieving expired attachments: %s", err.Error()) | ||||||
|  | 		} else if len(ids) > 0 { | ||||||
|  | 			log.Debug("Manager: Deleting expired attachments: %v", ids) | ||||||
| 			if err := s.fileCache.Remove(ids...); err != nil { | 			if err := s.fileCache.Remove(ids...); err != nil { | ||||||
| 				log.Printf("error while deleting attachments: %s", err.Error()) | 				log.Warn("Error deleting attachments: %s", err.Error()) | ||||||
| 			} | 			} | ||||||
| 		} else { | 		} else { | ||||||
| 			log.Printf("error retrieving expired attachments: %s", err.Error()) | 			log.Debug("Manager: No expired attachments to delete") | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Prune message cache | 	// Prune message cache | ||||||
| 	olderThan := time.Now().Add(-1 * s.config.CacheDuration) | 	olderThan := time.Now().Add(-1 * s.config.CacheDuration) | ||||||
|  | 	log.Debug("Manager: Pruning messages older than %s", olderThan.Format("2006-01-02 15:04:05")) | ||||||
| 	if err := s.messageCache.Prune(olderThan); err != nil { | 	if err := s.messageCache.Prune(olderThan); err != nil { | ||||||
| 		log.Printf("error pruning cache: %s", err.Error()) | 		log.Warn("Manager: Error pruning cache: %s", err.Error()) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Prune old topics, remove subscriptions without subscribers | 	// Prune old topics, remove subscriptions without subscribers | ||||||
|  | @ -1034,7 +1083,7 @@ func (s *Server) updateStatsAndPrune() { | ||||||
| 		subs := t.Subscribers() | 		subs := t.Subscribers() | ||||||
| 		msgs, err := s.messageCache.MessageCount(t.ID) | 		msgs, err := s.messageCache.MessageCount(t.ID) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error()) | 			log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error()) | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 		if msgs == 0 && subs == 0 { | 		if msgs == 0 && subs == 0 { | ||||||
|  | @ -1046,35 +1095,25 @@ func (s *Server) updateStatsAndPrune() { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Mail stats | 	// Mail stats | ||||||
| 	var mailSuccess, mailFailure int64 | 	var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64 | ||||||
| 	if s.smtpBackend != nil { | 	if s.smtpServerBackend != nil { | ||||||
| 		mailSuccess, mailFailure = s.smtpBackend.Counts() | 		receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() | ||||||
|  | 	} | ||||||
|  | 	var sentMailTotal, sentMailSuccess, sentMailFailure int64 | ||||||
|  | 	if s.smtpSender != nil { | ||||||
|  | 		sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts() | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Print stats | 	// Print stats | ||||||
| 	log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)", | 	log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)", | ||||||
| 		s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors)) | 		s.messages, messages, len(s.topics), subscribers, len(s.visitors), | ||||||
|  | 		receivedMailTotal, receivedMailSuccess, receivedMailFailure, | ||||||
|  | 		sentMailTotal, sentMailSuccess, sentMailFailure) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) runSMTPServer() error { | func (s *Server) runSMTPServer() error { | ||||||
| 	sub := func(m *message) error { | 	s.smtpServerBackend = newMailBackend(s.config, s.handle) | ||||||
| 		url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic) | 	s.smtpServer = smtp.NewServer(s.smtpServerBackend) | ||||||
| 		req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message)) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 		if m.Title != "" { |  | ||||||
| 			req.Header.Set("Title", m.Title) |  | ||||||
| 		} |  | ||||||
| 		rr := httptest.NewRecorder() |  | ||||||
| 		s.handle(rr, req) |  | ||||||
| 		if rr.Code != http.StatusOK { |  | ||||||
| 			return errors.New("error: " + rr.Body.String()) |  | ||||||
| 		} |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 	s.smtpBackend = newMailBackend(s.config, sub) |  | ||||||
| 	s.smtpServer = smtp.NewServer(s.smtpBackend) |  | ||||||
| 	s.smtpServer.Addr = s.config.SMTPServerListen | 	s.smtpServer.Addr = s.config.SMTPServerListen | ||||||
| 	s.smtpServer.Domain = s.config.SMTPServerDomain | 	s.smtpServer.Domain = s.config.SMTPServerDomain | ||||||
| 	s.smtpServer.ReadTimeout = 10 * time.Second | 	s.smtpServer.ReadTimeout = 10 * time.Second | ||||||
|  | @ -1096,32 +1135,29 @@ func (s *Server) runManager() { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) runAtSender() { | func (s *Server) runFirebaseKeepaliver() { | ||||||
|  | 	if s.firebaseClient == nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	v := newVisitor(s.config, s.messageCache, "0.0.0.0") // Background process, not a real visitor | ||||||
| 	for { | 	for { | ||||||
| 		select { | 		select { | ||||||
| 		case <-time.After(s.config.AtSenderInterval): | 		case <-time.After(s.config.FirebaseKeepaliveInterval): | ||||||
| 			if err := s.sendDelayedMessages(); err != nil { | 			s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic)) | ||||||
| 				log.Printf("error sending scheduled messages: %s", err.Error()) | 		case <-time.After(s.config.FirebasePollInterval): | ||||||
| 			} | 			s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic)) | ||||||
| 		case <-s.closeChan: | 		case <-s.closeChan: | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) runFirebaseKeepaliver() { | func (s *Server) runDelayedSender() { | ||||||
| 	if s.firebase == nil { |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	for { | 	for { | ||||||
| 		select { | 		select { | ||||||
| 		case <-time.After(s.config.FirebaseKeepaliveInterval): | 		case <-time.After(s.config.DelayedSenderInterval): | ||||||
| 			if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil { | 			if err := s.sendDelayedMessages(); err != nil { | ||||||
| 				log.Printf("error sending Firebase keepalive message to %s: %s", firebaseControlTopic, err.Error()) | 				log.Warn("Error sending delayed messages: %s", err.Error()) | ||||||
| 			} |  | ||||||
| 		case <-time.After(s.config.FirebasePollInterval): |  | ||||||
| 			if err := s.firebase(newKeepaliveMessage(firebasePollTopic)); err != nil { |  | ||||||
| 				log.Printf("error sending Firebase keepalive message to %s: %s", firebasePollTopic, err.Error()) |  | ||||||
| 			} | 			} | ||||||
| 		case <-s.closeChan: | 		case <-s.closeChan: | ||||||
| 			return | 			return | ||||||
|  | @ -1130,27 +1166,40 @@ func (s *Server) runFirebaseKeepaliver() { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) sendDelayedMessages() error { | func (s *Server) sendDelayedMessages() error { | ||||||
| 	s.mu.Lock() |  | ||||||
| 	defer s.mu.Unlock() |  | ||||||
| 	messages, err := s.messageCache.MessagesDue() | 	messages, err := s.messageCache.MessagesDue() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	for _, m := range messages { | 	for _, m := range messages { | ||||||
| 		t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published | 		v := s.visitorFromIP(m.Sender) | ||||||
| 		if ok { | 		if err := s.sendDelayedMessage(v, m); err != nil { | ||||||
| 			if err := t.Publish(m); err != nil { | 			log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error()) | ||||||
| 				log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error()) | 		} | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *Server) sendDelayedMessage(v *visitor, m *message) error { | ||||||
|  | 	s.mu.Lock() | ||||||
|  | 	defer s.mu.Unlock() | ||||||
|  | 	log.Debug("%s Sending delayed message", logMessagePrefix(v, m)) | ||||||
|  | 	t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published | ||||||
|  | 	if ok { | ||||||
|  | 		go func() { | ||||||
|  | 			// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler | ||||||
|  | 			if err := t.Publish(v, m); err != nil { | ||||||
|  | 				log.Warn("%s Unable to publish message: %v", logMessagePrefix(v, m), err.Error()) | ||||||
| 			} | 			} | ||||||
| 		} | 		}() | ||||||
| 		if s.firebase != nil { // Firebase subscribers may not show up in topics map | 	} | ||||||
| 			if err := s.firebase(m); err != nil { | 	if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map | ||||||
| 				log.Printf("unable to publish to Firebase: %v", err.Error()) | 		go s.sendToFirebase(v, m) | ||||||
| 			} | 	} | ||||||
| 		} | 	if s.config.UpstreamBaseURL != "" { | ||||||
| 		if err := s.messageCache.MarkPublished(m); err != nil { | 		go s.forwardPollRequest(v, m) | ||||||
| 			return err | 	} | ||||||
| 		} | 	if err := s.messageCache.MarkPublished(m); err != nil { | ||||||
|  | 		return err | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | @ -1252,13 +1301,13 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc { | ||||||
| 		username, password, ok := extractUserPass(r) | 		username, password, ok := extractUserPass(r) | ||||||
| 		if ok { | 		if ok { | ||||||
| 			if user, err = s.auth.Authenticate(username, password); err != nil { | 			if user, err = s.auth.Authenticate(username, password); err != nil { | ||||||
| 				log.Printf("authentication failed: %s", err.Error()) | 				log.Info("authentication failed: %s", err.Error()) | ||||||
| 				return errHTTPUnauthorized | 				return errHTTPUnauthorized | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		for _, t := range topics { | 		for _, t := range topics { | ||||||
| 			if err := s.auth.Authorize(user, t.ID, perm); err != nil { | 			if err := s.auth.Authorize(user, t.ID, perm); err != nil { | ||||||
| 				log.Printf("unauthorized: %s", err.Error()) | 				log.Info("unauthorized: %s", err.Error()) | ||||||
| 				return errHTTPForbidden | 				return errHTTPForbidden | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -1290,8 +1339,6 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool | ||||||
| // visitor creates or retrieves a rate.Limiter for the given visitor. | // visitor creates or retrieves a rate.Limiter for the given visitor. | ||||||
| // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). | // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). | ||||||
| func (s *Server) visitor(r *http.Request) *visitor { | func (s *Server) visitor(r *http.Request) *visitor { | ||||||
| 	s.mu.Lock() |  | ||||||
| 	defer s.mu.Unlock() |  | ||||||
| 	remoteAddr := r.RemoteAddr | 	remoteAddr := r.RemoteAddr | ||||||
| 	ip, _, err := net.SplitHostPort(remoteAddr) | 	ip, _, err := net.SplitHostPort(remoteAddr) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | @ -1300,6 +1347,12 @@ func (s *Server) visitor(r *http.Request) *visitor { | ||||||
| 	if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" { | 	if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" { | ||||||
| 		ip = r.Header.Get("X-Forwarded-For") | 		ip = r.Header.Get("X-Forwarded-For") | ||||||
| 	} | 	} | ||||||
|  | 	return s.visitorFromIP(ip) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *Server) visitorFromIP(ip string) *visitor { | ||||||
|  | 	s.mu.Lock() | ||||||
|  | 	defer s.mu.Unlock() | ||||||
| 	v, exists := s.visitors[ip] | 	v, exists := s.visitors[ip] | ||||||
| 	if !exists { | 	if !exists { | ||||||
| 		s.visitors[ip] = newVisitor(s.config, s.messageCache, ip) | 		s.visitors[ip] = newVisitor(s.config, s.messageCache, ip) | ||||||
|  |  | ||||||
|  | @ -178,3 +178,11 @@ | ||||||
| # | # | ||||||
| # visitor-attachment-total-size-limit: "100M" | # visitor-attachment-total-size-limit: "100M" | ||||||
| # visitor-attachment-daily-bandwidth-limit: "500M" | # visitor-attachment-daily-bandwidth-limit: "500M" | ||||||
|  | 
 | ||||||
|  | # Log level, can be TRACE, DEBUG, INFO, WARN or ERROR | ||||||
|  | # This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy". | ||||||
|  | # | ||||||
|  | # Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for | ||||||
|  | # debugging purposes, or your disk will fill up quickly. | ||||||
|  | # | ||||||
|  | # log-level: INFO | ||||||
|  |  | ||||||
|  | @ -3,13 +3,15 @@ package server | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
|  | 	"errors" | ||||||
|  | 	firebase "firebase.google.com/go/v4" | ||||||
|  | 	"firebase.google.com/go/v4/messaging" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"strings" |  | ||||||
| 
 |  | ||||||
| 	firebase "firebase.google.com/go" |  | ||||||
| 	"firebase.google.com/go/messaging" |  | ||||||
| 	"google.golang.org/api/option" | 	"google.golang.org/api/option" | ||||||
| 	"heckel.io/ntfy/auth" | 	"heckel.io/ntfy/auth" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
|  | 	"heckel.io/ntfy/util" | ||||||
|  | 	"strings" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | @ -17,25 +19,79 @@ const ( | ||||||
| 	fcmApnsBodyMessageLimit = 100 | 	fcmApnsBodyMessageLimit = 100 | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) { | var ( | ||||||
|  | 	errFirebaseQuotaExceeded     = errors.New("quota exceeded for Firebase messages to topic") | ||||||
|  | 	errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase") | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // firebaseClient is a generic client that formats and sends messages to Firebase. | ||||||
|  | // The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable. | ||||||
|  | type firebaseClient struct { | ||||||
|  | 	sender firebaseSender | ||||||
|  | 	auther auth.Auther | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient { | ||||||
|  | 	return &firebaseClient{ | ||||||
|  | 		sender: sender, | ||||||
|  | 		auther: auther, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *firebaseClient) Send(v *visitor, m *message) error { | ||||||
|  | 	if err := v.FirebaseAllowed(); err != nil { | ||||||
|  | 		return errFirebaseTemporarilyBanned | ||||||
|  | 	} | ||||||
|  | 	fbm, err := toFirebaseMessage(m, c.auther) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if log.IsTrace() { | ||||||
|  | 		log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm)) | ||||||
|  | 	} | ||||||
|  | 	err = c.sender.Send(fbm) | ||||||
|  | 	if err == errFirebaseQuotaExceeded { | ||||||
|  | 		log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m)) | ||||||
|  | 		v.FirebaseTemporarilyDeny() | ||||||
|  | 	} | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging. | ||||||
|  | // In tests, this can be implemented with a mock. | ||||||
|  | type firebaseSender interface { | ||||||
|  | 	// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded | ||||||
|  | 	// if a rate limit has reached. | ||||||
|  | 	Send(m *messaging.Message) error | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // firebaseSenderImpl is a firebaseSender that actually talks to Firebase | ||||||
|  | type firebaseSenderImpl struct { | ||||||
|  | 	client *messaging.Client | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) { | ||||||
| 	fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) | 	fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 	msg, err := fb.Messaging(context.Background()) | 	client, err := fb.Messaging(context.Background()) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 	return func(m *message) error { | 	return &firebaseSenderImpl{ | ||||||
| 		fbm, err := toFirebaseMessage(m, auther) | 		client: client, | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 		_, err = msg.Send(context.Background(), fbm) |  | ||||||
| 		return err |  | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *firebaseSenderImpl) Send(m *messaging.Message) error { | ||||||
|  | 	_, err := c.client.Send(context.Background(), m) | ||||||
|  | 	if err != nil && messaging.IsQuotaExceeded(err) { | ||||||
|  | 		return errFirebaseQuotaExceeded | ||||||
|  | 	} | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // toFirebaseMessage converts a message to a Firebase message. | // toFirebaseMessage converts a message to a Firebase message. | ||||||
| // | // | ||||||
| // Normal messages ("message"): | // Normal messages ("message"): | ||||||
|  |  | ||||||
|  | @ -3,11 +3,12 @@ package server | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"firebase.google.com/go/messaging" | 	"firebase.google.com/go/v4/messaging" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/stretchr/testify/require" | 	"github.com/stretchr/testify/require" | ||||||
| 	"heckel.io/ntfy/auth" | 	"heckel.io/ntfy/auth" | ||||||
| 	"strings" | 	"strings" | ||||||
|  | 	"sync" | ||||||
| 	"testing" | 	"testing" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -26,6 +27,35 @@ func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error { | ||||||
| 	return errors.New("unauthorized") | 	return errors.New("unauthorized") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | type testFirebaseSender struct { | ||||||
|  | 	allowed  int | ||||||
|  | 	messages []*messaging.Message | ||||||
|  | 	mu       sync.Mutex | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func newTestFirebaseSender(allowed int) *testFirebaseSender { | ||||||
|  | 	return &testFirebaseSender{ | ||||||
|  | 		allowed:  allowed, | ||||||
|  | 		messages: make([]*messaging.Message, 0), | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *testFirebaseSender) Send(m *messaging.Message) error { | ||||||
|  | 	s.mu.Lock() | ||||||
|  | 	defer s.mu.Unlock() | ||||||
|  | 	if len(s.messages)+1 > s.allowed { | ||||||
|  | 		return errFirebaseQuotaExceeded | ||||||
|  | 	} | ||||||
|  | 	s.messages = append(s.messages, m) | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *testFirebaseSender) Messages() []*messaging.Message { | ||||||
|  | 	s.mu.Lock() | ||||||
|  | 	defer s.mu.Unlock() | ||||||
|  | 	return append(make([]*messaging.Message, 0), s.messages...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func TestToFirebaseMessage_Keepalive(t *testing.T) { | func TestToFirebaseMessage_Keepalive(t *testing.T) { | ||||||
| 	m := newKeepaliveMessage("mytopic") | 	m := newKeepaliveMessage("mytopic") | ||||||
| 	fbm, err := toFirebaseMessage(m, nil) | 	fbm, err := toFirebaseMessage(m, nil) | ||||||
|  | @ -119,7 +149,6 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { | ||||||
| 		Size:    12345, | 		Size:    12345, | ||||||
| 		Expires: 98765543, | 		Expires: 98765543, | ||||||
| 		URL:     "https://example.com/file.jpg", | 		URL:     "https://example.com/file.jpg", | ||||||
| 		Owner:   "some-owner", |  | ||||||
| 	} | 	} | ||||||
| 	fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) | 	fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) | ||||||
| 	require.Nil(t, err) | 	require.Nil(t, err) | ||||||
|  | @ -286,3 +315,22 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) { | ||||||
| 	require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage)) | 	require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage)) | ||||||
| 	require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) | 	require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func TestToFirebaseSender_Abuse(t *testing.T) { | ||||||
|  | 	sender := &testFirebaseSender{allowed: 2} | ||||||
|  | 	client := newFirebaseClient(sender, &testAuther{}) | ||||||
|  | 	visitor := newVisitor(newTestConfig(t), newMemTestCache(t), "1.2.3.4") | ||||||
|  | 
 | ||||||
|  | 	require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) | ||||||
|  | 	require.Equal(t, 1, len(sender.Messages())) | ||||||
|  | 
 | ||||||
|  | 	require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) | ||||||
|  | 	require.Equal(t, 2, len(sender.Messages())) | ||||||
|  | 
 | ||||||
|  | 	require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"})) | ||||||
|  | 	require.Equal(t, 2, len(sender.Messages())) | ||||||
|  | 
 | ||||||
|  | 	sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working | ||||||
|  | 	require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"})) | ||||||
|  | 	require.Equal(t, 0, len(sender.Messages())) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -9,7 +9,6 @@ import ( | ||||||
| 	"math/rand" | 	"math/rand" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"os" |  | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
|  | @ -55,6 +54,21 @@ func TestServer_PublishAndPoll(t *testing.T) { | ||||||
| 	require.Equal(t, "my second  message", lines[1]) // \n -> " " | 	require.Equal(t, "my second  message", lines[1]) // \n -> " " | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func TestServer_PublishWithFirebase(t *testing.T) { | ||||||
|  | 	sender := newTestFirebaseSender(10) | ||||||
|  | 	s := newTestServer(t, newTestConfig(t)) | ||||||
|  | 	s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) | ||||||
|  | 
 | ||||||
|  | 	response := request(t, s, "PUT", "/mytopic", "my first message", nil) | ||||||
|  | 	msg1 := toMessage(t, response.Body.String()) | ||||||
|  | 	require.NotEmpty(t, msg1.ID) | ||||||
|  | 	require.Equal(t, "my first message", msg1.Message) | ||||||
|  | 	require.Equal(t, 1, len(sender.Messages())) | ||||||
|  | 	require.Equal(t, "my first message", sender.Messages()[0].Data["message"]) | ||||||
|  | 	require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body) | ||||||
|  | 	require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"]) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { | func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { | ||||||
| 	c := newTestConfig(t) | 	c := newTestConfig(t) | ||||||
| 	c.KeepaliveInterval = time.Second | 	c.KeepaliveInterval = time.Second | ||||||
|  | @ -264,7 +278,7 @@ func TestServer_PublishNoCache(t *testing.T) { | ||||||
| func TestServer_PublishAt(t *testing.T) { | func TestServer_PublishAt(t *testing.T) { | ||||||
| 	c := newTestConfig(t) | 	c := newTestConfig(t) | ||||||
| 	c.MinDelay = time.Second | 	c.MinDelay = time.Second | ||||||
| 	c.AtSenderInterval = 100 * time.Millisecond | 	c.DelayedSenderInterval = 100 * time.Millisecond | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ | 	response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ | ||||||
|  | @ -283,6 +297,13 @@ func TestServer_PublishAt(t *testing.T) { | ||||||
| 	messages = toMessages(t, response.Body.String()) | 	messages = toMessages(t, response.Body.String()) | ||||||
| 	require.Equal(t, 1, len(messages)) | 	require.Equal(t, 1, len(messages)) | ||||||
| 	require.Equal(t, "a message", messages[0].Message) | 	require.Equal(t, "a message", messages[0].Message) | ||||||
|  | 	require.Equal(t, "", messages[0].Sender) // Never return the sender! | ||||||
|  | 
 | ||||||
|  | 	messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true) | ||||||
|  | 	require.Nil(t, err) | ||||||
|  | 	require.Equal(t, 1, len(messages)) | ||||||
|  | 	require.Equal(t, "a message", messages[0].Message) | ||||||
|  | 	require.Equal(t, "9.9.9.9", messages[0].Sender) // It's stored in the DB though! | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestServer_PublishAtWithCacheError(t *testing.T) { | func TestServer_PublishAtWithCacheError(t *testing.T) { | ||||||
|  | @ -454,29 +475,9 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { | ||||||
| 	require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n ! | 	require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n ! | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestServer_PublishFirebase(t *testing.T) { |  | ||||||
| 	// This is unfortunately not much of a test, since it merely fires the messages towards Firebase, |  | ||||||
| 	// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back. |  | ||||||
| 	// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ... |  | ||||||
| 
 |  | ||||||
| 	c := newTestConfig(t) |  | ||||||
| 	c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test! |  | ||||||
| 	s := newTestServer(t, c) |  | ||||||
| 
 |  | ||||||
| 	// Normal message |  | ||||||
| 	response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil) |  | ||||||
| 	msg := toMessage(t, response.Body.String()) |  | ||||||
| 	require.NotEmpty(t, msg.ID) |  | ||||||
| 
 |  | ||||||
| 	// Keepalive message |  | ||||||
| 	require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic))) |  | ||||||
| 
 |  | ||||||
| 	time.Sleep(500 * time.Millisecond) // Time for sends |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func TestServer_PublishInvalidTopic(t *testing.T) { | func TestServer_PublishInvalidTopic(t *testing.T) { | ||||||
| 	s := newTestServer(t, newTestConfig(t)) | 	s := newTestServer(t, newTestConfig(t)) | ||||||
| 	s.mailer = &testMailer{} | 	s.smtpSender = &testMailer{} | ||||||
| 	response := request(t, s, "PUT", "/docs", "fail", nil) | 	response := request(t, s, "PUT", "/docs", "fail", nil) | ||||||
| 	require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) | 	require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) | ||||||
| } | } | ||||||
|  | @ -742,13 +743,17 @@ type testMailer struct { | ||||||
| 	mu    sync.Mutex | 	mu    sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (t *testMailer) Send(from, to string, m *message) error { | func (t *testMailer) Send(v *visitor, m *message, to string) error { | ||||||
| 	t.mu.Lock() | 	t.mu.Lock() | ||||||
| 	defer t.mu.Unlock() | 	defer t.mu.Unlock() | ||||||
| 	t.count++ | 	t.count++ | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (t *testMailer) Counts() (total int64, success int64, failure int64) { | ||||||
|  | 	return 0, 0, 0 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (t *testMailer) Count() int { | func (t *testMailer) Count() int { | ||||||
| 	t.mu.Lock() | 	t.mu.Lock() | ||||||
| 	defer t.mu.Unlock() | 	defer t.mu.Unlock() | ||||||
|  | @ -794,7 +799,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { | func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { | ||||||
| 	s := newTestServer(t, newTestConfig(t)) | 	s := newTestServer(t, newTestConfig(t)) | ||||||
| 	s.mailer = &testMailer{} | 	s.smtpSender = &testMailer{} | ||||||
| 	for i := 0; i < 16; i++ { | 	for i := 0; i < 16; i++ { | ||||||
| 		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ | 		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ | ||||||
| 			"E-Mail": "test@example.com", | 			"E-Mail": "test@example.com", | ||||||
|  | @ -811,7 +816,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { | ||||||
| 	c := newTestConfig(t) | 	c := newTestConfig(t) | ||||||
| 	c.VisitorEmailLimitReplenish = 500 * time.Millisecond | 	c.VisitorEmailLimitReplenish = 500 * time.Millisecond | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 	s.mailer = &testMailer{} | 	s.smtpSender = &testMailer{} | ||||||
| 	for i := 0; i < 16; i++ { | 	for i := 0; i < 16; i++ { | ||||||
| 		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ | 		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ | ||||||
| 			"E-Mail": "test@example.com", | 			"E-Mail": "test@example.com", | ||||||
|  | @ -837,7 +842,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| func TestServer_PublishDelayedEmail_Fail(t *testing.T) { | func TestServer_PublishDelayedEmail_Fail(t *testing.T) { | ||||||
| 	s := newTestServer(t, newTestConfig(t)) | 	s := newTestServer(t, newTestConfig(t)) | ||||||
| 	s.mailer = &testMailer{} | 	s.smtpSender = &testMailer{} | ||||||
| 	response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ | 	response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ | ||||||
| 		"E-Mail": "test@example.com", | 		"E-Mail": "test@example.com", | ||||||
| 		"Delay":  "20 min", | 		"Delay":  "20 min", | ||||||
|  | @ -955,7 +960,7 @@ func TestServer_PublishAsJSON(t *testing.T) { | ||||||
| func TestServer_PublishAsJSON_WithEmail(t *testing.T) { | func TestServer_PublishAsJSON_WithEmail(t *testing.T) { | ||||||
| 	mailer := &testMailer{} | 	mailer := &testMailer{} | ||||||
| 	s := newTestServer(t, newTestConfig(t)) | 	s := newTestServer(t, newTestConfig(t)) | ||||||
| 	s.mailer = mailer | 	s.smtpSender = mailer | ||||||
| 	body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` | 	body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` | ||||||
| 	response := request(t, s, "PUT", "/", body, nil) | 	response := request(t, s, "PUT", "/", body, nil) | ||||||
| 	require.Equal(t, 200, response.Code) | 	require.Equal(t, 200, response.Code) | ||||||
|  | @ -1018,7 +1023,7 @@ func TestServer_PublishAttachment(t *testing.T) { | ||||||
| 	require.Equal(t, int64(5000), msg.Attachment.Size) | 	require.Equal(t, int64(5000), msg.Attachment.Size) | ||||||
| 	require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours | 	require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours | ||||||
| 	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") | 	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") | ||||||
| 	require.Equal(t, "", msg.Attachment.Owner) // Should never be returned | 	require.Equal(t, "", msg.Sender) // Should never be returned | ||||||
| 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) | 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) | ||||||
| 
 | 
 | ||||||
| 	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") | 	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") | ||||||
|  | @ -1047,7 +1052,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { | ||||||
| 	require.Equal(t, int64(21), msg.Attachment.Size) | 	require.Equal(t, int64(21), msg.Attachment.Size) | ||||||
| 	require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) | 	require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) | ||||||
| 	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") | 	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") | ||||||
| 	require.Equal(t, "", msg.Attachment.Owner) // Should never be returned | 	require.Equal(t, "", msg.Sender) // Should never be returned | ||||||
| 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) | 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) | ||||||
| 
 | 
 | ||||||
| 	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") | 	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") | ||||||
|  | @ -1074,7 +1079,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { | ||||||
| 	require.Equal(t, "", msg.Attachment.Type) | 	require.Equal(t, "", msg.Attachment.Type) | ||||||
| 	require.Equal(t, int64(0), msg.Attachment.Size) | 	require.Equal(t, int64(0), msg.Attachment.Size) | ||||||
| 	require.Equal(t, int64(0), msg.Attachment.Expires) | 	require.Equal(t, int64(0), msg.Attachment.Expires) | ||||||
| 	require.Equal(t, "", msg.Attachment.Owner) | 	require.Equal(t, "", msg.Sender) | ||||||
| 
 | 
 | ||||||
| 	// Slightly unrelated cross-test: make sure we don't add an owner for external attachments | 	// Slightly unrelated cross-test: make sure we don't add an owner for external attachments | ||||||
| 	size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1") | 	size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1") | ||||||
|  | @ -1095,7 +1100,7 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { | ||||||
| 	require.Equal(t, "", msg.Attachment.Type) | 	require.Equal(t, "", msg.Attachment.Type) | ||||||
| 	require.Equal(t, int64(0), msg.Attachment.Size) | 	require.Equal(t, int64(0), msg.Attachment.Size) | ||||||
| 	require.Equal(t, int64(0), msg.Attachment.Expires) | 	require.Equal(t, int64(0), msg.Attachment.Expires) | ||||||
| 	require.Equal(t, "", msg.Attachment.Owner) | 	require.Equal(t, "", msg.Sender) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestServer_PublishAttachmentBadURL(t *testing.T) { | func TestServer_PublishAttachmentBadURL(t *testing.T) { | ||||||
|  | @ -1333,18 +1338,6 @@ func toHTTPError(t *testing.T, s string) *errHTTP { | ||||||
| 	return &e | 	return &e | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func firebaseServiceAccountFile(t *testing.T) string { |  | ||||||
| 	if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" { |  | ||||||
| 		return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") |  | ||||||
| 	} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" { |  | ||||||
| 		filename := filepath.Join(t.TempDir(), "firebase.json") |  | ||||||
| 		require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0o600)) |  | ||||||
| 		return filename |  | ||||||
| 	} |  | ||||||
| 	t.SkipNow() |  | ||||||
| 	return "" |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func basicAuth(s string) string { | func basicAuth(s string) string { | ||||||
| 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s))) | 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s))) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,33 +4,62 @@ import ( | ||||||
| 	_ "embed" // required by go:embed | 	_ "embed" // required by go:embed | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
| 	"mime" | 	"mime" | ||||||
| 	"net" | 	"net" | ||||||
| 	"net/smtp" | 	"net/smtp" | ||||||
| 	"strings" | 	"strings" | ||||||
|  | 	"sync" | ||||||
| 	"time" | 	"time" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type mailer interface { | type mailer interface { | ||||||
| 	Send(from, to string, m *message) error | 	Send(v *visitor, m *message, to string) error | ||||||
|  | 	Counts() (total int64, success int64, failure int64) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type smtpSender struct { | type smtpSender struct { | ||||||
| 	config *Config | 	config  *Config | ||||||
|  | 	success int64 | ||||||
|  | 	failure int64 | ||||||
|  | 	mu      sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *smtpSender) Send(senderIP, to string, m *message) error { | func (s *smtpSender) Send(v *visitor, m *message, to string) error { | ||||||
| 	host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr) | 	return s.withCount(v, m, func() error { | ||||||
|  | 		host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		message, err := formatMail(s.config.BaseURL, v.ip, s.config.SMTPSenderFrom, to, m) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) | ||||||
|  | 		log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to) | ||||||
|  | 		log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message) | ||||||
|  | 		return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *smtpSender) Counts() (total int64, success int64, failure int64) { | ||||||
|  | 	s.mu.Lock() | ||||||
|  | 	defer s.mu.Unlock() | ||||||
|  | 	return s.success + s.failure, s.success, s.failure | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error { | ||||||
|  | 	err := fn() | ||||||
|  | 	s.mu.Lock() | ||||||
|  | 	defer s.mu.Unlock() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error()) | ||||||
|  | 		s.failure++ | ||||||
|  | 	} else { | ||||||
|  | 		s.success++ | ||||||
| 	} | 	} | ||||||
| 	message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m) | 	return err | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) |  | ||||||
| 	return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) { | func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) { | ||||||
|  |  | ||||||
|  | @ -3,10 +3,15 @@ package server | ||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"errors" | 	"errors" | ||||||
|  | 	"fmt" | ||||||
| 	"github.com/emersion/go-smtp" | 	"github.com/emersion/go-smtp" | ||||||
|  | 	"heckel.io/ntfy/log" | ||||||
| 	"io" | 	"io" | ||||||
| 	"mime" | 	"mime" | ||||||
| 	"mime/multipart" | 	"mime/multipart" | ||||||
|  | 	"net" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/http/httptest" | ||||||
| 	"net/mail" | 	"net/mail" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
|  | @ -23,49 +28,55 @@ var ( | ||||||
| // smtpBackend implements SMTP server methods. | // smtpBackend implements SMTP server methods. | ||||||
| type smtpBackend struct { | type smtpBackend struct { | ||||||
| 	config  *Config | 	config  *Config | ||||||
| 	sub     subscriber | 	handler func(http.ResponseWriter, *http.Request) | ||||||
| 	success int64 | 	success int64 | ||||||
| 	failure int64 | 	failure int64 | ||||||
| 	mu      sync.Mutex | 	mu      sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func newMailBackend(conf *Config, sub subscriber) *smtpBackend { | func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend { | ||||||
| 	return &smtpBackend{ | 	return &smtpBackend{ | ||||||
| 		config: conf, | 		config:  conf, | ||||||
| 		sub:    sub, | 		handler: handler, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { | func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { | ||||||
| 	return &smtpSession{backend: b}, nil | 	log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username) | ||||||
|  | 	return &smtpSession{backend: b, state: state}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { | func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { | ||||||
| 	return &smtpSession{backend: b}, nil | 	log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state)) | ||||||
|  | 	return &smtpSession{backend: b, state: state}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (b *smtpBackend) Counts() (success int64, failure int64) { | func (b *smtpBackend) Counts() (total int64, success int64, failure int64) { | ||||||
| 	b.mu.Lock() | 	b.mu.Lock() | ||||||
| 	defer b.mu.Unlock() | 	defer b.mu.Unlock() | ||||||
| 	return b.success, b.failure | 	return b.success + b.failure, b.success, b.failure | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // smtpSession is returned after EHLO. | // smtpSession is returned after EHLO. | ||||||
| type smtpSession struct { | type smtpSession struct { | ||||||
| 	backend *smtpBackend | 	backend *smtpBackend | ||||||
|  | 	state   *smtp.ConnectionState | ||||||
| 	topic   string | 	topic   string | ||||||
| 	mu      sync.Mutex | 	mu      sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *smtpSession) AuthPlain(username, password string) error { | func (s *smtpSession) AuthPlain(username, password string) error { | ||||||
|  | 	log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username) | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error { | func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error { | ||||||
|  | 	log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts) | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *smtpSession) Rcpt(to string) error { | func (s *smtpSession) Rcpt(to string) error { | ||||||
|  | 	log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to) | ||||||
| 	return s.withFailCount(func() error { | 	return s.withFailCount(func() error { | ||||||
| 		conf := s.backend.config | 		conf := s.backend.config | ||||||
| 		addressList, err := mail.ParseAddressList(to) | 		addressList, err := mail.ParseAddressList(to) | ||||||
|  | @ -102,6 +113,11 @@ func (s *smtpSession) Data(r io.Reader) error { | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  | 		if log.IsTrace() { | ||||||
|  | 			log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b)) | ||||||
|  | 		} else if log.IsDebug() { | ||||||
|  | 			log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b)) | ||||||
|  | 		} | ||||||
| 		msg, err := mail.ReadMessage(bytes.NewReader(b)) | 		msg, err := mail.ReadMessage(bytes.NewReader(b)) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
|  | @ -128,7 +144,7 @@ func (s *smtpSession) Data(r io.Reader) error { | ||||||
| 			m.Message = m.Title // Flip them, this makes more sense | 			m.Message = m.Title // Flip them, this makes more sense | ||||||
| 			m.Title = "" | 			m.Title = "" | ||||||
| 		} | 		} | ||||||
| 		if err := s.backend.sub(m); err != nil { | 		if err := s.publishMessage(m); err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		s.backend.mu.Lock() | 		s.backend.mu.Lock() | ||||||
|  | @ -138,6 +154,33 @@ func (s *smtpSession) Data(r io.Reader) error { | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (s *smtpSession) publishMessage(m *message) error { | ||||||
|  | 	// Extract remote address (for rate limiting) | ||||||
|  | 	remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String()) | ||||||
|  | 	if err != nil { | ||||||
|  | 		remoteAddr = s.state.RemoteAddr.String() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Call HTTP handler with fake HTTP request | ||||||
|  | 	url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic) | ||||||
|  | 	req, err := http.NewRequest("POST", url, strings.NewReader(m.Message)) | ||||||
|  | 	req.RequestURI = "/" + m.Topic // just for the logs | ||||||
|  | 	req.RemoteAddr = remoteAddr    // rate limiting!! | ||||||
|  | 	req.Header.Set("X-Forwarded-For", remoteAddr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if m.Title != "" { | ||||||
|  | 		req.Header.Set("Title", m.Title) | ||||||
|  | 	} | ||||||
|  | 	rr := httptest.NewRecorder() | ||||||
|  | 	s.backend.handler(rr, req) | ||||||
|  | 	if rr.Code != http.StatusOK { | ||||||
|  | 		return errors.New("error: " + rr.Body.String()) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (s *smtpSession) Reset() { | func (s *smtpSession) Reset() { | ||||||
| 	s.mu.Lock() | 	s.mu.Lock() | ||||||
| 	s.topic = "" | 	s.topic = "" | ||||||
|  | @ -153,6 +196,9 @@ func (s *smtpSession) withFailCount(fn func() error) error { | ||||||
| 	s.backend.mu.Lock() | 	s.backend.mu.Lock() | ||||||
| 	defer s.backend.mu.Unlock() | 	defer s.backend.mu.Unlock() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		// Almost all of these errors are parse errors, and user input errors. | ||||||
|  | 		// We do not want to spam the log with WARN messages. | ||||||
|  | 		log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error()) | ||||||
| 		s.backend.failure++ | 		s.backend.failure++ | ||||||
| 	} | 	} | ||||||
| 	return err | 	return err | ||||||
|  |  | ||||||
|  | @ -3,6 +3,9 @@ package server | ||||||
| import ( | import ( | ||||||
| 	"github.com/emersion/go-smtp" | 	"github.com/emersion/go-smtp" | ||||||
| 	"github.com/stretchr/testify/require" | 	"github.com/stretchr/testify/require" | ||||||
|  | 	"io" | ||||||
|  | 	"net" | ||||||
|  | 	"net/http" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
| ) | ) | ||||||
|  | @ -27,13 +30,12 @@ Content-Type: text/html; charset="UTF-8" | ||||||
| <div dir="ltr">what's up<br clear="all"><div><br></div></div> | <div dir="ltr">what's up<br clear="all"><div><br></div></div> | ||||||
| 
 | 
 | ||||||
| --000000000000f3320b05d42915c9--` | --000000000000f3320b05d42915c9--` | ||||||
| 	_, backend := newTestBackend(t, func(m *message) error { | 	_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		require.Equal(t, "mytopic", m.Topic) | 		require.Equal(t, "/mytopic", r.URL.Path) | ||||||
| 		require.Equal(t, "and one more", m.Title) | 		require.Equal(t, "and one more", r.Header.Get("Title")) | ||||||
| 		require.Equal(t, "what's up", m.Message) | 		require.Equal(t, "what's up", readAll(t, r.Body)) | ||||||
| 		return nil |  | ||||||
| 	}) | 	}) | ||||||
| 	session, _ := backend.AnonymousLogin(nil) | 	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) | 	require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) | ||||||
| 	require.Nil(t, session.Data(strings.NewReader(email))) | 	require.Nil(t, session.Data(strings.NewReader(email))) | ||||||
|  | @ -59,13 +61,12 @@ Content-Type: text/html; charset="UTF-8" | ||||||
| <div dir="ltr"><br></div> | <div dir="ltr"><br></div> | ||||||
| 
 | 
 | ||||||
| --000000000000bcf4a405d429f8d4--` | --000000000000bcf4a405d429f8d4--` | ||||||
| 	_, backend := newTestBackend(t, func(m *message) error { | 	_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		require.Equal(t, "emailtest", m.Topic) | 		require.Equal(t, "/emailtest", r.URL.Path) | ||||||
| 		require.Equal(t, "", m.Title) // We flipped message and body | 		require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body | ||||||
| 		require.Equal(t, "This email has a subject but no body", m.Message) | 		require.Equal(t, "This email has a subject but no body", readAll(t, r.Body)) | ||||||
| 		return nil |  | ||||||
| 	}) | 	}) | ||||||
| 	session, _ := backend.AnonymousLogin(nil) | 	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh")) | 	require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh")) | ||||||
| 	require.Nil(t, session.Data(strings.NewReader(email))) | 	require.Nil(t, session.Data(strings.NewReader(email))) | ||||||
|  | @ -81,14 +82,13 @@ Content-Type: text/plain; charset="UTF-8" | ||||||
| 
 | 
 | ||||||
| what's up | what's up | ||||||
| ` | ` | ||||||
| 	conf, backend := newTestBackend(t, func(m *message) error { | 	conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		require.Equal(t, "mytopic", m.Topic) | 		require.Equal(t, "/mytopic", r.URL.Path) | ||||||
| 		require.Equal(t, "and one more", m.Title) | 		require.Equal(t, "and one more", r.Header.Get("Title")) | ||||||
| 		require.Equal(t, "what's up", m.Message) | 		require.Equal(t, "what's up", readAll(t, r.Body)) | ||||||
| 		return nil |  | ||||||
| 	}) | 	}) | ||||||
| 	conf.SMTPServerAddrPrefix = "" | 	conf.SMTPServerAddrPrefix = "" | ||||||
| 	session, _ := backend.AnonymousLogin(nil) | 	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | ||||||
| 	require.Nil(t, session.Data(strings.NewReader(email))) | 	require.Nil(t, session.Data(strings.NewReader(email))) | ||||||
|  | @ -99,14 +99,13 @@ func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| what's up | what's up | ||||||
| ` | ` | ||||||
| 	conf, backend := newTestBackend(t, func(m *message) error { | 	conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		require.Equal(t, "mytopic", m.Topic) | 		require.Equal(t, "/mytopic", r.URL.Path) | ||||||
| 		require.Equal(t, "Very short mail", m.Title) | 		require.Equal(t, "Very short mail", r.Header.Get("Title")) | ||||||
| 		require.Equal(t, "what's up", m.Message) | 		require.Equal(t, "what's up", readAll(t, r.Body)) | ||||||
| 		return nil |  | ||||||
| 	}) | 	}) | ||||||
| 	conf.SMTPServerAddrPrefix = "" | 	conf.SMTPServerAddrPrefix = "" | ||||||
| 	session, _ := backend.AnonymousLogin(nil) | 	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | ||||||
| 	require.Nil(t, session.Data(strings.NewReader(email))) | 	require.Nil(t, session.Data(strings.NewReader(email))) | ||||||
|  | @ -121,11 +120,10 @@ Content-Type: text/plain; charset="UTF-8" | ||||||
| 
 | 
 | ||||||
| what's up | what's up | ||||||
| ` | ` | ||||||
| 	_, backend := newTestBackend(t, func(m *message) error { | 	_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		require.Equal(t, "Three santas 🎅🎅🎅", m.Title) | 		require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title")) | ||||||
| 		return nil |  | ||||||
| 	}) | 	}) | ||||||
| 	session, _ := backend.AnonymousLogin(nil) | 	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) | 	require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) | ||||||
| 	require.Nil(t, session.Data(strings.NewReader(email))) | 	require.Nil(t, session.Data(strings.NewReader(email))) | ||||||
|  | @ -204,7 +202,7 @@ BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB | ||||||
| BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB | BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB | ||||||
| that should do it | that should do it | ||||||
| ` | ` | ||||||
| 	conf, backend := newTestBackend(t, func(m *message) error { | 	conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		expected := `you know this is a string. | 		expected := `you know this is a string. | ||||||
| it's a long string. | it's a long string. | ||||||
| it's supposed to be longer than the max message length | it's supposed to be longer than the max message length | ||||||
|  | @ -266,13 +264,12 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | ||||||
| ...................................................................... | ...................................................................... | ||||||
| ...................................................................... | ...................................................................... | ||||||
| and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB | and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB | ||||||
| BBBBBBBBBBBBBBBBBBBBBBBB` | BBBBBBBBBBBBBBBBBBBBBBBBB` | ||||||
| 		require.Equal(t, 4096, len(expected)) // Sanity check | 		require.Equal(t, 4096, len(expected)) // Sanity check | ||||||
| 		require.Equal(t, expected, m.Message) | 		require.Equal(t, expected, readAll(t, r.Body)) | ||||||
| 		return nil |  | ||||||
| 	}) | 	}) | ||||||
| 	conf.SMTPServerAddrPrefix = "" | 	conf.SMTPServerAddrPrefix = "" | ||||||
| 	session, _ := backend.AnonymousLogin(nil) | 	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | ||||||
| 	require.Nil(t, session.Data(strings.NewReader(email))) | 	require.Nil(t, session.Data(strings.NewReader(email))) | ||||||
|  | @ -288,21 +285,41 @@ Content-Type: text/SOMETHINGELSE | ||||||
| 
 | 
 | ||||||
| what's up | what's up | ||||||
| ` | ` | ||||||
| 	conf, backend := newTestBackend(t, func(m *message) error { | 	conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) { | ||||||
| 		return nil | 		// Nothing. | ||||||
| 	}) | 	}) | ||||||
| 	conf.SMTPServerAddrPrefix = "" | 	conf.SMTPServerAddrPrefix = "" | ||||||
| 	session, _ := backend.Login(nil, "user", "pass") | 	session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass") | ||||||
| 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) | ||||||
| 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) | ||||||
| 	require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email))) | 	require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email))) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) { | func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) { | ||||||
| 	conf := newTestConfig(t) | 	conf := newTestConfig(t) | ||||||
| 	conf.SMTPServerListen = ":25" | 	conf.SMTPServerListen = ":25" | ||||||
| 	conf.SMTPServerDomain = "ntfy.sh" | 	conf.SMTPServerDomain = "ntfy.sh" | ||||||
| 	conf.SMTPServerAddrPrefix = "ntfy-" | 	conf.SMTPServerAddrPrefix = "ntfy-" | ||||||
| 	backend := newMailBackend(conf, sub) | 	backend := newMailBackend(conf, handler) | ||||||
| 	return conf, backend | 	return conf, backend | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func readAll(t *testing.T, rc io.ReadCloser) string { | ||||||
|  | 	b, err := io.ReadAll(rc) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	return string(b) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState { | ||||||
|  | 	ip, err := net.ResolveIPAddr("ip", remoteAddr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	return &smtp.ConnectionState{ | ||||||
|  | 		Hostname:   "myhostname", | ||||||
|  | 		LocalAddr:  ip, | ||||||
|  | 		RemoteAddr: ip, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| package server | package server | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"log" | 	"heckel.io/ntfy/log" | ||||||
| 	"math/rand" | 	"math/rand" | ||||||
| 	"sync" | 	"sync" | ||||||
| ) | ) | ||||||
|  | @ -15,7 +15,7 @@ type topic struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // subscriber is a function that is called for every new message on a topic | // subscriber is a function that is called for every new message on a topic | ||||||
| type subscriber func(msg *message) error | type subscriber func(v *visitor, msg *message) error | ||||||
| 
 | 
 | ||||||
| // newTopic creates a new topic | // newTopic creates a new topic | ||||||
| func newTopic(id string) *topic { | func newTopic(id string) *topic { | ||||||
|  | @ -42,14 +42,19 @@ func (t *topic) Unsubscribe(id int) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Publish asynchronously publishes to all subscribers | // Publish asynchronously publishes to all subscribers | ||||||
| func (t *topic) Publish(m *message) error { | func (t *topic) Publish(v *visitor, m *message) error { | ||||||
| 	go func() { | 	go func() { | ||||||
| 		t.mu.Lock() | 		t.mu.Lock() | ||||||
| 		defer t.mu.Unlock() | 		defer t.mu.Unlock() | ||||||
| 		for _, s := range t.subscribers { | 		if len(t.subscribers) > 0 { | ||||||
| 			if err := s(m); err != nil { | 			log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers)) | ||||||
| 				log.Printf("error publishing message to subscriber") | 			for _, s := range t.subscribers { | ||||||
|  | 				if err := s(v, m); err != nil { | ||||||
|  | 					log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m)) | ||||||
|  | 				} | ||||||
| 			} | 			} | ||||||
|  | 		} else { | ||||||
|  | 			log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m)) | ||||||
| 		} | 		} | ||||||
| 	}() | 	}() | ||||||
| 	return nil | 	return nil | ||||||
|  |  | ||||||
|  | @ -32,6 +32,7 @@ type message struct { | ||||||
| 	Actions    []*action   `json:"actions,omitempty"` | 	Actions    []*action   `json:"actions,omitempty"` | ||||||
| 	Attachment *attachment `json:"attachment,omitempty"` | 	Attachment *attachment `json:"attachment,omitempty"` | ||||||
| 	PollID     string      `json:"poll_id,omitempty"` | 	PollID     string      `json:"poll_id,omitempty"` | ||||||
|  | 	Sender     string      `json:"-"`                  // IP address of uploader, used for rate limiting | ||||||
| 	Encoding   string      `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes | 	Encoding   string      `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -41,7 +42,6 @@ type attachment struct { | ||||||
| 	Size    int64  `json:"size,omitempty"` | 	Size    int64  `json:"size,omitempty"` | ||||||
| 	Expires int64  `json:"expires,omitempty"` | 	Expires int64  `json:"expires,omitempty"` | ||||||
| 	URL     string `json:"url"` | 	URL     string `json:"url"` | ||||||
| 	Owner   string `json:"-"` // IP address of uploader, used for rate limiting |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type action struct { | type action struct { | ||||||
|  |  | ||||||
|  | @ -1,6 +1,8 @@ | ||||||
| package server | package server | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"github.com/emersion/go-smtp" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" | 	"strings" | ||||||
| ) | ) | ||||||
|  | @ -40,3 +42,19 @@ func readQueryParam(r *http.Request, names ...string) string { | ||||||
| 	} | 	} | ||||||
| 	return "" | 	return "" | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func logMessagePrefix(v *visitor, m *message) string { | ||||||
|  | 	return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func logHTTPPrefix(v *visitor, r *http.Request) string { | ||||||
|  | 	requestURI := r.RequestURI | ||||||
|  | 	if requestURI == "" { | ||||||
|  | 		requestURI = r.URL.Path | ||||||
|  | 	} | ||||||
|  | 	return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func logSMTPPrefix(state *smtp.ConnectionState) string { | ||||||
|  | 	return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -28,6 +28,7 @@ type visitor struct { | ||||||
| 	emails        *rate.Limiter | 	emails        *rate.Limiter | ||||||
| 	subscriptions util.Limiter | 	subscriptions util.Limiter | ||||||
| 	bandwidth     util.Limiter | 	bandwidth     util.Limiter | ||||||
|  | 	firebase      time.Time // Next allowed Firebase message | ||||||
| 	seen          time.Time | 	seen          time.Time | ||||||
| 	mu            sync.Mutex | 	mu            sync.Mutex | ||||||
| } | } | ||||||
|  | @ -48,14 +49,11 @@ func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor { | ||||||
| 		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), | 		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), | ||||||
| 		subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), | 		subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), | ||||||
| 		bandwidth:     util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), | 		bandwidth:     util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), | ||||||
|  | 		firebase:      time.Unix(0, 0), | ||||||
| 		seen:          time.Now(), | 		seen:          time.Now(), | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (v *visitor) IP() string { |  | ||||||
| 	return v.ip |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (v *visitor) RequestAllowed() error { | func (v *visitor) RequestAllowed() error { | ||||||
| 	if !v.requests.Allow() { | 	if !v.requests.Allow() { | ||||||
| 		return errVisitorLimitReached | 		return errVisitorLimitReached | ||||||
|  | @ -63,6 +61,21 @@ func (v *visitor) RequestAllowed() error { | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (v *visitor) FirebaseAllowed() error { | ||||||
|  | 	v.mu.Lock() | ||||||
|  | 	defer v.mu.Unlock() | ||||||
|  | 	if time.Now().Before(v.firebase) { | ||||||
|  | 		return errVisitorLimitReached | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (v *visitor) FirebaseTemporarilyDeny() { | ||||||
|  | 	v.mu.Lock() | ||||||
|  | 	defer v.mu.Unlock() | ||||||
|  | 	v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (v *visitor) EmailAllowed() error { | func (v *visitor) EmailAllowed() error { | ||||||
| 	if !v.emails.Allow() { | 	if !v.emails.Allow() { | ||||||
| 		return errVisitorLimitReached | 		return errVisitorLimitReached | ||||||
|  |  | ||||||
|  | @ -2,8 +2,8 @@ package main | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	firebase "firebase.google.com/go" | 	firebase "firebase.google.com/go/v4" | ||||||
| 	"firebase.google.com/go/messaging" | 	"firebase.google.com/go/v4/messaging" | ||||||
| 	"flag" | 	"flag" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"google.golang.org/api/option" | 	"google.golang.org/api/option" | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								util/util.go
									
										
									
									
									
								
							
							
						
						
									
										14
									
								
								util/util.go
									
										
									
									
									
								
							|  | @ -2,6 +2,7 @@ package util | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/base64" | 	"encoding/base64" | ||||||
|  | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/gabriel-vasile/mimetype" | 	"github.com/gabriel-vasile/mimetype" | ||||||
|  | @ -264,3 +265,16 @@ func ReadPassword(in io.Reader) ([]byte, error) { | ||||||
| func BasicAuth(user, pass string) string { | func BasicAuth(user, pass string) string { | ||||||
| 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass)))) | 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass)))) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed. | ||||||
|  | // This is useful for logging purposes where a failure doesn't matter that much. | ||||||
|  | func MaybeMarshalJSON(v interface{}) string { | ||||||
|  | 	jsonBytes, err := json.MarshalIndent(v, "", "  ") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "<cannot serialize>" | ||||||
|  | 	} | ||||||
|  | 	if len(jsonBytes) > 5000 { | ||||||
|  | 		return string(jsonBytes)[:5000] | ||||||
|  | 	} | ||||||
|  | 	return string(jsonBytes) | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										967
									
								
								web/package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										967
									
								
								web/package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -110,7 +110,7 @@ | ||||||
|     <p> |     <p> | ||||||
|         <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a> |         <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a> | ||||||
|         <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a> |         <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a> | ||||||
|         <a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="static/img/badge-appstore.png"></a> |         <a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a> | ||||||
|     </p> |     </p> | ||||||
|     <p> |     <p> | ||||||
|         Here's a video showing the app in action: |         Here's a video showing the app in action: | ||||||
|  |  | ||||||
|  | @ -1,7 +1,191 @@ | ||||||
| { | { | ||||||
|     "action_bar_settings": "Instellingen", |     "action_bar_settings": "Instellingen", | ||||||
|     "action_bar_send_test_notification": "Stuur testmelding", |     "action_bar_send_test_notification": "Stuur test notificatie", | ||||||
|     "action_bar_clear_notifications": "Alle meldingen wissen", |     "action_bar_clear_notifications": "Wis alle notificaties", | ||||||
|     "message_bar_type_message": "Typ hier een bericht", |     "message_bar_type_message": "Typ hier een bericht", | ||||||
|     "action_bar_unsubscribe": "Afmelden" |     "action_bar_unsubscribe": "Afmelden", | ||||||
|  |     "message_bar_error_publishing": "Fout bij publiceren notificatie", | ||||||
|  |     "nav_topics_title": "Geabonneerde onderwerpen", | ||||||
|  |     "nav_button_settings": "Instellingen", | ||||||
|  |     "alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.", | ||||||
|  |     "notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.", | ||||||
|  |     "publish_dialog_tags_label": "Tags", | ||||||
|  |     "publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen", | ||||||
|  |     "prefs_users_dialog_title_edit": "Gebruiker bewerken", | ||||||
|  |     "error_boundary_title": "Oh nee, ntfy is vastgelopen", | ||||||
|  |     "error_boundary_description": "Dit hoort natuurlijk niet te gebeuren. Onze excuses.<br/>Wanneer het mogelijk is, <githubLink>meld deze fout op GitHub</githubLink>, of laat het ons weten via <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.", | ||||||
|  |     "error_boundary_button_copy_stack_trace": "Stack trace kopiëren", | ||||||
|  |     "error_boundary_stack_trace": "Stacktrace", | ||||||
|  |     "error_boundary_gathering_info": "Meer informatie verzamelen …", | ||||||
|  |     "prefs_users_delete_button": "Gebruiker verwijderen", | ||||||
|  |     "prefs_notifications_delete_after_one_week": "Na één week", | ||||||
|  |     "prefs_notifications_delete_after_one_month": "Na één maand", | ||||||
|  |     "prefs_users_dialog_title_add": "Gebruiker toevoegen", | ||||||
|  |     "prefs_users_dialog_password_label": "Wachtwoord", | ||||||
|  |     "error_boundary_unsupported_indexeddb_description": "De ntfy web applicatie heeft IndexedDB nodig om correct te kunnen functioneren, helaas ondersteund jouw browser IndexedDB niet in privé / incognito modus.<br/><br/>Dit is jammer maar het is ook onlogisch om de ntfy web applicatie in privé / incognito modus te gebruiken want alle gegevens worden bewaard in de browser zijn lokale opslag. Je kan hier meer over lezen <githubLink>in deze GitHub issue</githubLink>, of praat met ons op <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.", | ||||||
|  |     "action_bar_show_menu": "Toon menu", | ||||||
|  |     "action_bar_logo_alt": "ntfy logo", | ||||||
|  |     "action_bar_toggle_mute": "Notificaties dempen/opheffen", | ||||||
|  |     "action_bar_toggle_action_menu": "Actie menu openen/sluiten", | ||||||
|  |     "message_bar_show_dialog": "Toon publicatie venster", | ||||||
|  |     "message_bar_publish": "Bericht publiceren", | ||||||
|  |     "nav_button_all_notifications": "Alle notificaties", | ||||||
|  |     "nav_button_documentation": "Documentatie", | ||||||
|  |     "nav_button_publish_message": "Notificatie publiceren", | ||||||
|  |     "nav_button_subscribe": "Onderwerp abonneren", | ||||||
|  |     "nav_button_muted": "Notificaties gedempt", | ||||||
|  |     "nav_button_connecting": "verbinden", | ||||||
|  |     "alert_grant_title": "Notificaties zijn uitgeschakeld", | ||||||
|  |     "alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.", | ||||||
|  |     "alert_grant_button": "Nu toestaan", | ||||||
|  |     "alert_not_supported_title": "Notificaties zijn niet ondersteund", | ||||||
|  |     "notifications_list": "Notificaties lijst", | ||||||
|  |     "notifications_list_item": "Notificatie", | ||||||
|  |     "notifications_mark_read": "Markeer als gelezen", | ||||||
|  |     "notifications_delete": "Verwijder", | ||||||
|  |     "notifications_copied_to_clipboard": "Gekopieerd naar klembord", | ||||||
|  |     "notifications_tags": "Tags", | ||||||
|  |     "notifications_priority_x": "Prioriteit {{priority}}", | ||||||
|  |     "notifications_new_indicator": "Nieuwe notificatie", | ||||||
|  |     "notifications_attachment_image": "Afbeelding bijlage", | ||||||
|  |     "notifications_attachment_copy_url_title": "Kopieer URL van bijlage naar klembord", | ||||||
|  |     "notifications_attachment_copy_url_button": "URL kopiëren", | ||||||
|  |     "notifications_attachment_open_title": "Ga naar {{url}}", | ||||||
|  |     "notifications_attachment_open_button": "Bijlage openen", | ||||||
|  |     "notifications_attachment_link_expires": "link vervalt op {{date}}", | ||||||
|  |     "notifications_attachment_link_expired": "download link is verlopen", | ||||||
|  |     "notifications_attachment_file_image": "afbeeldingsbestand", | ||||||
|  |     "notifications_attachment_file_video": "videobestand", | ||||||
|  |     "notifications_attachment_file_audio": "audiobestand", | ||||||
|  |     "notifications_attachment_file_app": "Android app bestand", | ||||||
|  |     "notifications_attachment_file_document": "overig document", | ||||||
|  |     "notifications_click_copy_url_title": "URL naar klembord kopiëren", | ||||||
|  |     "notifications_click_copy_url_button": "Link kopiëren", | ||||||
|  |     "notifications_click_open_button": "Link openen", | ||||||
|  |     "notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL.", | ||||||
|  |     "notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL. Hier is een voorbeeld met één van je onderwerpen.", | ||||||
|  |     "notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.", | ||||||
|  |     "notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.", | ||||||
|  |     "notifications_example": "Voorbeeld", | ||||||
|  |     "notifications_more_details": "Voor meer informatie, bezoek de <websiteLink>website</websiteLink> of <docsLink>documentatie</docsLink>.", | ||||||
|  |     "notifications_loading": "Notificaties laden …", | ||||||
|  |     "publish_dialog_title_topic": "Publiceren naar {{topic}}", | ||||||
|  |     "publish_dialog_title_no_topic": "Notificatie publiceren", | ||||||
|  |     "publish_dialog_progress_uploading": "Uploaden …", | ||||||
|  |     "notifications_actions_open_url_title": "Ga naar {{url}}", | ||||||
|  |     "notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie", | ||||||
|  |     "notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}", | ||||||
|  |     "notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.", | ||||||
|  |     "publish_dialog_priority_low": "Lage prioriteit", | ||||||
|  |     "publish_dialog_progress_uploading_detail": "Uploaden {{loaded}}/{{total}} ({{percent}}%) …", | ||||||
|  |     "publish_dialog_message_published": "Notificatie gepubliceerd", | ||||||
|  |     "publish_dialog_attachment_limits_file_and_quota_reached": "overschrijd {{fileSizeLimit}} bestandslimiet en quotum, {{remainingBytes}} resterend", | ||||||
|  |     "publish_dialog_attachment_limits_file_reached": "overschrijd {{fileSizeLimit}} bestandslimiet", | ||||||
|  |     "publish_dialog_priority_default": "Standaard prioriteit", | ||||||
|  |     "publish_dialog_attachment_limits_quota_reached": "overschrijd quotum, {{remainingBytes}} resterend", | ||||||
|  |     "publish_dialog_emoji_picker_show": "Kies een emoji", | ||||||
|  |     "publish_dialog_priority_high": "Hoge prioriteit", | ||||||
|  |     "publish_dialog_priority_max": "Maximale prioriteit", | ||||||
|  |     "publish_dialog_priority_min": "Minimale prioriteit", | ||||||
|  |     "publish_dialog_base_url_label": "Service URL", | ||||||
|  |     "publish_dialog_base_url_placeholder": "Service URL, bijvoorbeeld: https://voorbeeld.com", | ||||||
|  |     "publish_dialog_topic_label": "Onderwerp", | ||||||
|  |     "publish_dialog_topic_placeholder": "Onderwerp, bijv. phil_alerts", | ||||||
|  |     "publish_dialog_topic_reset": "Onderwerp resetten", | ||||||
|  |     "publish_dialog_title_label": "Titel", | ||||||
|  |     "publish_dialog_title_placeholder": "Notificatie titel , bijv. Schijfruimte alarm", | ||||||
|  |     "publish_dialog_message_label": "Bericht", | ||||||
|  |     "publish_dialog_message_placeholder": "Typ hier een bericht", | ||||||
|  |     "publish_dialog_tags_placeholder": "Komma gescheiden lijst met tags, bijv. waarschuwing, srv1-backup", | ||||||
|  |     "publish_dialog_priority_label": "Prioriteit", | ||||||
|  |     "publish_dialog_click_label": "Klik URL", | ||||||
|  |     "publish_dialog_click_reset": "Verwijder klik URL", | ||||||
|  |     "publish_dialog_email_label": "Email", | ||||||
|  |     "publish_dialog_email_placeholder": "Adres om de notificatie naar door te sturen, bijv. phil@voorbeeld.com", | ||||||
|  |     "publish_dialog_email_reset": "Email doorsturen verwijderen", | ||||||
|  |     "publish_dialog_attach_label": "URL van bijlage", | ||||||
|  |     "publish_dialog_click_placeholder": "URL die geopend zal worden wanneer op de notificatie geklikt wordt", | ||||||
|  |     "publish_dialog_attach_placeholder": "Bestand bijvoegen via URL, bijv. https://f-droid.org/F-Droid.apk", | ||||||
|  |     "publish_dialog_attach_reset": "Bijlage URL verwijderen", | ||||||
|  |     "publish_dialog_filename_label": "Bestandsnaam", | ||||||
|  |     "publish_dialog_filename_placeholder": "Bestandsnaam van bijlage", | ||||||
|  |     "publish_dialog_delay_label": "Uitstellen", | ||||||
|  |     "publish_dialog_delay_placeholder": "Bezorging uitstellen, bijv. {{unixTimestamp}}, {{relativeTime}}, of \"{{naturalLanguage}}\" (alleen Engels)", | ||||||
|  |     "publish_dialog_delay_reset": "Verwijder uitgestelde bezorging", | ||||||
|  |     "publish_dialog_other_features": "Andere functionaliteiten:", | ||||||
|  |     "publish_dialog_chip_click_label": "Klik URL", | ||||||
|  |     "publish_dialog_chip_email_label": "Doorsturen naar email", | ||||||
|  |     "publish_dialog_chip_attach_url_label": "Bestand bijvoegen via URL", | ||||||
|  |     "publish_dialog_chip_delay_label": "Uitgestelde bezorging", | ||||||
|  |     "publish_dialog_chip_topic_label": "Onderwerp veranderen", | ||||||
|  |     "publish_dialog_details_examples_description": "Voor meer voorbeelden en gedetailleerde beschrijvingen van alle functionaliteiten, bekijk de <docsLink>documentatie</docsLink>.", | ||||||
|  |     "publish_dialog_button_cancel_sending": "Versturen annuleren", | ||||||
|  |     "publish_dialog_button_cancel": "Annuleer", | ||||||
|  |     "publish_dialog_button_send": "Verstuur", | ||||||
|  |     "publish_dialog_checkbox_publish_another": "Nog een bericht versturen", | ||||||
|  |     "publish_dialog_attached_file_title": "Bijgevoegd bestand:", | ||||||
|  |     "publish_dialog_attached_file_filename_placeholder": "Bijlage bestandsnaam", | ||||||
|  |     "publish_dialog_attached_file_remove": "Verwijder bijgevoegd bestand", | ||||||
|  |     "publish_dialog_drop_file_here": "Bestand hier slepen", | ||||||
|  |     "emoji_picker_search_placeholder": "Emoji zoeken", | ||||||
|  |     "emoji_picker_search_clear": "Zoeken leegmaken", | ||||||
|  |     "subscribe_dialog_subscribe_topic_placeholder": "Onderwerp naam, bijv. phils_waarschuwingen", | ||||||
|  |     "subscribe_dialog_subscribe_use_another_label": "Gebruik een andere server", | ||||||
|  |     "subscribe_dialog_subscribe_base_url_label": "Service URL", | ||||||
|  |     "subscribe_dialog_subscribe_button_cancel": "Annuleren", | ||||||
|  |     "subscribe_dialog_subscribe_button_subscribe": "Abonneren", | ||||||
|  |     "subscribe_dialog_login_title": "Aanmelding vereist", | ||||||
|  |     "subscribe_dialog_login_description": "Dit onderwerp is beveiligd met een wachtwoord. Geef een gebruikersnaam en wachtwoord op om te abonneren.", | ||||||
|  |     "subscribe_dialog_login_username_label": "Gebruikersnaam, bijv. phil", | ||||||
|  |     "subscribe_dialog_subscribe_title": "Onderwerp abonneren", | ||||||
|  |     "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.", | ||||||
|  |     "subscribe_dialog_login_password_label": "Wachtwoord", | ||||||
|  |     "subscribe_dialog_login_button_back": "Terug", | ||||||
|  |     "subscribe_dialog_login_button_login": "Aanmelden", | ||||||
|  |     "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", | ||||||
|  |     "subscribe_dialog_error_user_anonymous": "anoniem", | ||||||
|  |     "prefs_notifications_title": "Notificaties", | ||||||
|  |     "prefs_notifications_sound_title": "Meldingsgeluid", | ||||||
|  |     "prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven", | ||||||
|  |     "prefs_notifications_sound_play": "Geselecteerd geluid afspelen", | ||||||
|  |     "prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} afspelen", | ||||||
|  |     "prefs_notifications_sound_no_sound": "Geen geluid", | ||||||
|  |     "prefs_notifications_min_priority_title": "Minimale prioriteit", | ||||||
|  |     "prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit", | ||||||
|  |     "prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit is {{number}} ({{name}}) of hoger", | ||||||
|  |     "prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit is 5 (maximaal)", | ||||||
|  |     "prefs_notifications_min_priority_any": "Elke prioriteit", | ||||||
|  |     "prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger", | ||||||
|  |     "prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger", | ||||||
|  |     "prefs_notifications_min_priority_high_and_higher": "Hoge prioriteit en hoger", | ||||||
|  |     "prefs_notifications_min_priority_max_only": "Alleen maximale prioriteit", | ||||||
|  |     "prefs_notifications_delete_after_title": "Notificaties verwijderen", | ||||||
|  |     "prefs_notifications_delete_after_never": "Nooit", | ||||||
|  |     "prefs_notifications_delete_after_three_hours": "Na drie uur", | ||||||
|  |     "prefs_notifications_delete_after_one_day": "Na één dag", | ||||||
|  |     "prefs_notifications_delete_after_never_description": "Notificaties worden nooit automatisch verwijderd", | ||||||
|  |     "prefs_notifications_delete_after_three_hours_description": "Notificaties worden na drie uur automatisch verwijderd", | ||||||
|  |     "prefs_notifications_delete_after_one_day_description": "Notificaties worden na één dag automatisch verwijderd", | ||||||
|  |     "prefs_notifications_delete_after_one_week_description": "Notificaties worden na één week automatisch verwijderd", | ||||||
|  |     "prefs_notifications_delete_after_one_month_description": "Notificaties worden na één maand automatisch verwijderd", | ||||||
|  |     "prefs_users_title": "Gebruikers beheren", | ||||||
|  |     "prefs_users_description": "Gebruikers voor beveiligde onderwerpen kunnen hier toegevoegd of verwijderd worden. Let op: gebruikersnaam en wachtwoord worden opgeslagen in lokale browser opslag.", | ||||||
|  |     "prefs_users_table": "Gebruikerstabel", | ||||||
|  |     "prefs_users_add_button": "Gebruiker toevoegen", | ||||||
|  |     "prefs_users_edit_button": "Gebruiker bewerken", | ||||||
|  |     "prefs_users_table_user_header": "Gebruiker", | ||||||
|  |     "prefs_users_table_base_url_header": "Service URL", | ||||||
|  |     "prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh", | ||||||
|  |     "prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil", | ||||||
|  |     "prefs_users_dialog_button_cancel": "Annuleren", | ||||||
|  |     "prefs_users_dialog_button_add": "Toevoegen", | ||||||
|  |     "prefs_users_dialog_button_save": "Bewaren", | ||||||
|  |     "prefs_appearance_title": "Weergave", | ||||||
|  |     "prefs_appearance_language_title": "Taal", | ||||||
|  |     "priority_min": "min", | ||||||
|  |     "priority_low": "laag", | ||||||
|  |     "priority_default": "standaard", | ||||||
|  |     "priority_high": "hoog", | ||||||
|  |     "priority_max": "max", | ||||||
|  |     "error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund" | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										191
									
								
								web/public/static/langs/zh_Hans.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								web/public/static/langs/zh_Hans.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,191 @@ | ||||||
|  | { | ||||||
|  |     "action_bar_show_menu": "显示菜单", | ||||||
|  |     "action_bar_logo_alt": "ntfy图标", | ||||||
|  |     "action_bar_settings": "设置", | ||||||
|  |     "action_bar_send_test_notification": "发送测试通知", | ||||||
|  |     "action_bar_clear_notifications": "清除所有通知", | ||||||
|  |     "action_bar_unsubscribe": "取消订阅", | ||||||
|  |     "action_bar_toggle_action_menu": "开启或关闭操作菜单", | ||||||
|  |     "message_bar_type_message": "在此处输入消息", | ||||||
|  |     "message_bar_show_dialog": "显示发布对话框", | ||||||
|  |     "message_bar_publish": "发布消息", | ||||||
|  |     "nav_topics_title": "订阅主题", | ||||||
|  |     "nav_button_all_notifications": "全部通知", | ||||||
|  |     "nav_button_documentation": "文档", | ||||||
|  |     "nav_button_publish_message": "发布通知", | ||||||
|  |     "nav_button_subscribe": "订阅主题", | ||||||
|  |     "nav_button_connecting": "正在连接", | ||||||
|  |     "alert_grant_title": "已禁用通知", | ||||||
|  |     "alert_grant_description": "授予浏览器显示桌面通知的权限。", | ||||||
|  |     "alert_grant_button": "现在授予", | ||||||
|  |     "alert_not_supported_title": "不支持通知", | ||||||
|  |     "alert_not_supported_description": "您的浏览器不支持通知。", | ||||||
|  |     "notifications_list": "通知列表", | ||||||
|  |     "notifications_list_item": "通知", | ||||||
|  |     "notifications_mark_read": "标记为已读", | ||||||
|  |     "notifications_copied_to_clipboard": "复制到剪贴板", | ||||||
|  |     "notifications_tags": "标记", | ||||||
|  |     "notifications_priority_x": "优先级 {{priority}}", | ||||||
|  |     "notifications_new_indicator": "新通知", | ||||||
|  |     "notifications_attachment_open_button": "打开附件", | ||||||
|  |     "notifications_attachment_link_expires": "链接过期 {{date}}", | ||||||
|  |     "notifications_attachment_link_expired": "下载链接已过期", | ||||||
|  |     "notifications_attachment_file_image": "图片文件", | ||||||
|  |     "notifications_attachment_image": "附件图片", | ||||||
|  |     "notifications_attachment_file_video": "视频文件", | ||||||
|  |     "notifications_attachment_file_audio": "音频文件", | ||||||
|  |     "notifications_attachment_file_app": "安卓应用文件", | ||||||
|  |     "notifications_attachment_file_document": "其他文件", | ||||||
|  |     "notifications_click_copy_url_title": "复制链接地址到剪贴板", | ||||||
|  |     "notifications_click_copy_url_button": "复制链接", | ||||||
|  |     "notifications_click_open_button": "打开链接", | ||||||
|  |     "action_bar_toggle_mute": "暂停或恢复通知", | ||||||
|  |     "nav_button_muted": "已暂停通知", | ||||||
|  |     "notifications_actions_not_supported": "网页应用程序不支持操作", | ||||||
|  |     "notifications_none_for_topic_title": "您尚未收到有关此主题的任何通知。", | ||||||
|  |     "notifications_none_for_any_title": "您尚未收到任何通知。", | ||||||
|  |     "notifications_none_for_any_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。以下是使用您的主题的示例。", | ||||||
|  |     "notifications_no_subscriptions_title": "看起来你还没有任何订阅。", | ||||||
|  |     "notifications_example": "示例", | ||||||
|  |     "notifications_more_details": "有关更多信息,请查看<websiteLink>网站</websiteLink>或<docsLink>文档</docsLink>。", | ||||||
|  |     "notifications_loading": "正在加载通知……", | ||||||
|  |     "publish_dialog_title_topic": "发布到 {{topic}}", | ||||||
|  |     "publish_dialog_title_no_topic": "发布通知", | ||||||
|  |     "publish_dialog_progress_uploading": "正在上传……", | ||||||
|  |     "publish_dialog_progress_uploading_detail": "正在上传 {{loaded}}/{{total}} ({{percent}}%) ……", | ||||||
|  |     "publish_dialog_message_published": "已发布通知", | ||||||
|  |     "publish_dialog_attachment_limits_file_and_quota_reached": "超过 {{fileSizeLimit}} 文件限制和配额,剩余 {{remainingBytes}}", | ||||||
|  |     "publish_dialog_emoji_picker_show": "选择表情符号", | ||||||
|  |     "publish_dialog_priority_min": "最低优先级", | ||||||
|  |     "publish_dialog_priority_low": "低优先级", | ||||||
|  |     "publish_dialog_priority_default": "默认优先级", | ||||||
|  |     "publish_dialog_priority_high": "高优先级", | ||||||
|  |     "publish_dialog_priority_max": "最高优先级", | ||||||
|  |     "publish_dialog_topic_label": "主题名称", | ||||||
|  |     "publish_dialog_topic_placeholder": "主题名称,例如 phil_alerts", | ||||||
|  |     "publish_dialog_topic_reset": "重置主题", | ||||||
|  |     "publish_dialog_title_label": "主题", | ||||||
|  |     "publish_dialog_message_label": "消息", | ||||||
|  |     "publish_dialog_message_placeholder": "在此输入消息", | ||||||
|  |     "publish_dialog_tags_label": "标记", | ||||||
|  |     "publish_dialog_priority_label": "优先级", | ||||||
|  |     "publish_dialog_base_url_label": "服务链接地址", | ||||||
|  |     "publish_dialog_base_url_placeholder": "服务链接地址,例如 https://example.com", | ||||||
|  |     "publish_dialog_click_label": "点击链接地址", | ||||||
|  |     "publish_dialog_click_placeholder": "点击通知时打开链接地址", | ||||||
|  |     "publish_dialog_email_placeholder": "将通知转发到的地址,例如 phil@example.com", | ||||||
|  |     "publish_dialog_email_reset": "移除电子邮件转发", | ||||||
|  |     "publish_dialog_filename_label": "文件名", | ||||||
|  |     "publish_dialog_filename_placeholder": "附件文件名", | ||||||
|  |     "publish_dialog_delay_label": "延期", | ||||||
|  |     "publish_dialog_other_features": "其它功能:", | ||||||
|  |     "publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk", | ||||||
|  |     "publish_dialog_delay_reset": "删除延迟交付", | ||||||
|  |     "publish_dialog_attach_reset": "移除附件链接地址", | ||||||
|  |     "publish_dialog_chip_click_label": "点击链接地址", | ||||||
|  |     "publish_dialog_chip_email_label": "转发邮件", | ||||||
|  |     "publish_dialog_chip_attach_file_label": "本地文件附件", | ||||||
|  |     "publish_dialog_chip_topic_label": "变更主题", | ||||||
|  |     "publish_dialog_button_cancel_sending": "取消发送", | ||||||
|  |     "publish_dialog_checkbox_publish_another": "发布另一个", | ||||||
|  |     "publish_dialog_attached_file_title": "附件文件:", | ||||||
|  |     "publish_dialog_attached_file_filename_placeholder": "附件文件名", | ||||||
|  |     "publish_dialog_attached_file_remove": "删除附件文件", | ||||||
|  |     "publish_dialog_drop_file_here": "将文件拖拽至此", | ||||||
|  |     "emoji_picker_search_placeholder": "查找表情符号", | ||||||
|  |     "emoji_picker_search_clear": "清除搜索", | ||||||
|  |     "subscribe_dialog_subscribe_title": "订阅主题", | ||||||
|  |     "publish_dialog_chip_delay_label": "延迟交付", | ||||||
|  |     "publish_dialog_chip_attach_url_label": "链接附件地址", | ||||||
|  |     "subscribe_dialog_subscribe_use_another_label": "使用其他服务器", | ||||||
|  |     "subscribe_dialog_subscribe_button_subscribe": "订阅", | ||||||
|  |     "subscribe_dialog_login_title": "请登录", | ||||||
|  |     "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", | ||||||
|  |     "subscribe_dialog_login_username_label": "用户名,例如 phil", | ||||||
|  |     "subscribe_dialog_login_password_label": "密码", | ||||||
|  |     "subscribe_dialog_login_button_back": "返回", | ||||||
|  |     "subscribe_dialog_login_button_login": "登录", | ||||||
|  |     "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", | ||||||
|  |     "subscribe_dialog_error_user_anonymous": "匿名", | ||||||
|  |     "prefs_notifications_title": "通知", | ||||||
|  |     "prefs_notifications_sound_title": "通知提示音", | ||||||
|  |     "prefs_notifications_sound_description_none": "收到通知时不播放任何声音", | ||||||
|  |     "prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音", | ||||||
|  |     "prefs_notifications_sound_no_sound": "静音", | ||||||
|  |     "prefs_notifications_sound_play": "播放选中声音", | ||||||
|  |     "prefs_notifications_min_priority_title": "最低优先级", | ||||||
|  |     "prefs_notifications_min_priority_description_x_or_higher": "仅显示优先级为{{number}}({{name}})或以上的通知", | ||||||
|  |     "prefs_notifications_min_priority_description_max": "仅显示最高优先级的通知", | ||||||
|  |     "prefs_notifications_min_priority_any": "任意优先级", | ||||||
|  |     "prefs_notifications_min_priority_low_and_higher": "低优先级和更高优先级", | ||||||
|  |     "prefs_notifications_min_priority_default_and_higher": "默认优先级或更高优先级", | ||||||
|  |     "prefs_notifications_min_priority_high_and_higher": "高优先级或更高优先级", | ||||||
|  |     "prefs_notifications_min_priority_max_only": "仅最高优先级", | ||||||
|  |     "prefs_notifications_delete_after_never": "从不", | ||||||
|  |     "prefs_notifications_delete_after_one_month": "一月后", | ||||||
|  |     "prefs_notifications_delete_after_one_week": "一周后", | ||||||
|  |     "prefs_notifications_delete_after_never_description": "永不自动删除通知", | ||||||
|  |     "prefs_notifications_delete_after_three_hours_description": "三小时后自动删除通知", | ||||||
|  |     "prefs_notifications_delete_after_one_day_description": "一天后自动删除通知", | ||||||
|  |     "prefs_notifications_delete_after_one_week_description": "一周后自动删除通知", | ||||||
|  |     "prefs_notifications_delete_after_one_month_description": "一月后后自动删除通知", | ||||||
|  |     "prefs_users_title": "管理用户", | ||||||
|  |     "prefs_users_description": "在此处添加/删除受保护主题的用户。请注意,用户名和密码存储在浏览器的本地存储中。", | ||||||
|  |     "prefs_users_add_button": "添加用户", | ||||||
|  |     "prefs_users_dialog_title_add": "添加用户", | ||||||
|  |     "prefs_users_dialog_title_edit": "编辑用户", | ||||||
|  |     "prefs_users_dialog_username_label": "用户名,例如 phil", | ||||||
|  |     "prefs_users_dialog_password_label": "密码", | ||||||
|  |     "prefs_users_dialog_button_cancel": "取消", | ||||||
|  |     "prefs_users_dialog_button_save": "保存", | ||||||
|  |     "prefs_appearance_title": "外观", | ||||||
|  |     "prefs_appearance_language_title": "语言", | ||||||
|  |     "priority_min": "最低", | ||||||
|  |     "priority_low": "低", | ||||||
|  |     "priority_default": "默认", | ||||||
|  |     "priority_high": "高", | ||||||
|  |     "priority_max": "最高", | ||||||
|  |     "error_boundary_title": "天啊,ntfy 崩溃了", | ||||||
|  |     "prefs_users_table_base_url_header": "服务链接地址", | ||||||
|  |     "prefs_users_dialog_base_url_label": "服务链接地址,例如 https://ntfy.sh", | ||||||
|  |     "error_boundary_button_copy_stack_trace": "复制堆栈跟踪", | ||||||
|  |     "error_boundary_stack_trace": "堆栈跟踪", | ||||||
|  |     "error_boundary_gathering_info": "收集更多信息……", | ||||||
|  |     "error_boundary_unsupported_indexeddb_title": "不支持隐私浏览", | ||||||
|  |     "error_boundary_unsupported_indexeddb_description": "Ntfy Web应用程序需要IndexedDB才能运行,并且您的浏览器在私隐私浏览模式下不支持IndexedDB。<br/><br/>虽然这很不幸,但在隐私浏览模式下使用ntfy Web应用程序也没有多大意义,因为所有东西都存储在浏览器存储中。您可以在<githubLink>本GitHub问题</githubLink>中阅读有关它的更多信息,或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上与我们交谈。", | ||||||
|  |     "message_bar_error_publishing": "发布通知时出错", | ||||||
|  |     "nav_button_settings": "设置", | ||||||
|  |     "notifications_delete": "删除", | ||||||
|  |     "notifications_attachment_copy_url_title": "将附件中链接地址复制到剪贴板", | ||||||
|  |     "notifications_attachment_copy_url_button": "复制链接地址", | ||||||
|  |     "notifications_attachment_open_title": "转到 {{url}}", | ||||||
|  |     "notifications_actions_http_request_title": "发送 HTTP {{method}} 到 {{url}}", | ||||||
|  |     "notifications_actions_open_url_title": "转到 {{url}}", | ||||||
|  |     "notifications_none_for_topic_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。", | ||||||
|  |     "subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts", | ||||||
|  |     "notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。", | ||||||
|  |     "publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制", | ||||||
|  |     "publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警", | ||||||
|  |     "publish_dialog_email_label": "电子邮件", | ||||||
|  |     "publish_dialog_button_send": "发送", | ||||||
|  |     "publish_dialog_attachment_limits_quota_reached": "超过配额,剩余 {{remainingBytes}}", | ||||||
|  |     "publish_dialog_attach_label": "附件链接地址", | ||||||
|  |     "publish_dialog_click_reset": "移除点击连接地址", | ||||||
|  |     "publish_dialog_button_cancel": "取消", | ||||||
|  |     "subscribe_dialog_subscribe_button_cancel": "取消", | ||||||
|  |     "subscribe_dialog_subscribe_base_url_label": "服务地址地址", | ||||||
|  |     "prefs_notifications_min_priority_description_any": "显示所有通知,无论优先级如何", | ||||||
|  |     "prefs_notifications_delete_after_title": "删除通知", | ||||||
|  |     "prefs_notifications_delete_after_three_hours": "三小时后", | ||||||
|  |     "prefs_users_delete_button": "删除用户", | ||||||
|  |     "prefs_users_table_user_header": "用户", | ||||||
|  |     "prefs_users_dialog_button_add": "添加", | ||||||
|  |     "prefs_notifications_delete_after_one_day": "一天后", | ||||||
|  |     "error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。", | ||||||
|  |     "prefs_users_table": "用户表", | ||||||
|  |     "prefs_users_edit_button": "编辑用户", | ||||||
|  |     "publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup", | ||||||
|  |     "publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。", | ||||||
|  |     "subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易猜测的名字。订阅后,您可以使用 PUT/POST 通知。", | ||||||
|  |     "publish_dialog_delay_placeholder": "延迟交付,例如{{unixTimestamp}}、{{relativeTime}}或“{{naturalLanguage}}”(仅限英语)" | ||||||
|  | } | ||||||
|  | @ -436,7 +436,7 @@ const Appearance = () => { | ||||||
| const Language = () => { | const Language = () => { | ||||||
|     const { t, i18n } = useTranslation(); |     const { t, i18n } = useTranslation(); | ||||||
|     const labelId = "prefLanguage"; |     const labelId = "prefLanguage"; | ||||||
|     const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇮🇹", "🇭🇺", "🇧🇷", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); |     const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); | ||||||
|     const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); |     const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); | ||||||
|     const lang = i18n.language ?? "en"; |     const lang = i18n.language ?? "en"; | ||||||
| 
 | 
 | ||||||
|  | @ -452,12 +452,14 @@ const Language = () => { | ||||||
|                     <MenuItem value="id">Bahasa Indonesia</MenuItem> |                     <MenuItem value="id">Bahasa Indonesia</MenuItem> | ||||||
|                     <MenuItem value="bg">Български</MenuItem> |                     <MenuItem value="bg">Български</MenuItem> | ||||||
|                     <MenuItem value="cs">Čeština</MenuItem> |                     <MenuItem value="cs">Čeština</MenuItem> | ||||||
|  |                     <MenuItem value="zh_Hans">中文</MenuItem> | ||||||
|                     <MenuItem value="de">Deutsch</MenuItem> |                     <MenuItem value="de">Deutsch</MenuItem> | ||||||
|                     <MenuItem value="es">Español</MenuItem> |                     <MenuItem value="es">Español</MenuItem> | ||||||
|                     <MenuItem value="fr">Français</MenuItem> |                     <MenuItem value="fr">Français</MenuItem> | ||||||
|                     <MenuItem value="it">Italiano</MenuItem> |                     <MenuItem value="it">Italiano</MenuItem> | ||||||
|                     <MenuItem value="hu">Magyar</MenuItem> |                     <MenuItem value="hu">Magyar</MenuItem> | ||||||
|                     <MenuItem value="ja">日本語</MenuItem> |                     <MenuItem value="ja">日本語</MenuItem> | ||||||
|  |                     <MenuItem value="nl">Nederlands</MenuItem> | ||||||
|                     <MenuItem value="nb_NO">Norsk bokmål</MenuItem> |                     <MenuItem value="nb_NO">Norsk bokmål</MenuItem> | ||||||
|                     <MenuItem value="pt_BR">Português (Brasil)</MenuItem> |                     <MenuItem value="pt_BR">Português (Brasil)</MenuItem> | ||||||
|                     <MenuItem value="ru">Русский</MenuItem> |                     <MenuItem value="ru">Русский</MenuItem> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue