Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0521f19ea4 | ||
|
|
17930caf21 | ||
|
|
d65ca9b10f | ||
|
|
ae3163c5b1 | ||
|
|
887a7c3288 | ||
|
|
f6dee345b7 | ||
|
|
1e16899ae3 | ||
|
|
7475879712 | ||
|
|
997828aa72 | ||
|
|
f6ffb393f8 | ||
|
|
850c6725f5 | ||
|
|
39b1de3320 | ||
|
|
e12995e218 | ||
|
|
5cc0b194d3 | ||
|
|
7845eb0124 | ||
|
|
3fa825b104 | ||
|
|
732537eaba | ||
|
|
a898a2ebe8 | ||
|
|
430f985fca | ||
|
|
ab955d4d1c | ||
|
|
41fd8454cf | ||
|
|
bd865fd55d | ||
|
|
b9e5079399 | ||
|
|
eb0847392c | ||
|
|
17eabed11c | ||
|
|
ad55de784d | ||
|
|
48538d149e | ||
|
|
b60f0afb8f | ||
|
|
8c32f029fb | ||
|
|
5b9391be39 | ||
|
|
a04cf5fcb6 | ||
|
|
9202d85532 | ||
|
|
769e071593 | ||
|
|
c80e4e1aa9 | ||
|
|
f9284a098a | ||
|
|
8283b6be97 | ||
|
|
8a81c8e95b | ||
|
|
670ea67052 | ||
|
|
aaa004847c | ||
|
|
717d6287c8 | ||
|
|
dcfb19bfc9 | ||
|
|
dc0e699fb5 | ||
|
|
1f38a4a531 | ||
|
|
970ca3a68e | ||
|
|
ce7c8c43b5 | ||
|
|
4a6f4e0044 | ||
|
|
7e3ac9b76b | ||
|
|
3939599014 | ||
|
|
15aed00387 | ||
|
|
d1544991bf | ||
|
|
d24f2d9d46 | ||
|
|
b2c2bd1e4b | ||
|
|
b003d79ae4 | ||
|
|
a52b024807 | ||
|
|
12b83828bd | ||
|
|
96bb357435 | ||
|
|
6a43c1a126 | ||
|
|
4dabc56952 | ||
|
|
5e510a19a1 | ||
|
|
b627a327d1 | ||
|
|
0b38efd761 | ||
|
|
983dec801a | ||
|
|
01eeb71b9d | ||
|
|
6ba1d7b2a5 | ||
|
|
ff202a042b | ||
|
|
af76a2606d | ||
|
|
98b56c2f06 | ||
|
|
b6afa2fd49 | ||
|
|
e1c07228e5 | ||
|
|
a949748d91 | ||
|
|
125fcd85bb | ||
|
|
2abd6a57ee | ||
|
|
35a691a1bc |
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
|
||||||
44
.github/workflows/test.yaml
vendored
44
.github/workflows/test.yaml
vendored
@@ -3,26 +3,46 @@ on: [push, pull_request]
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install Go
|
-
|
||||||
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ playground/
|
|||||||
secrets/
|
secrets/
|
||||||
*.iml
|
*.iml
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
|||||||
|
|
||||||
COPY ntfy /usr/bin
|
COPY ntfy /usr/bin
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
EXPOSE 80/tcp
|
||||||
ENTRYPOINT ["ntfy"]
|
ENTRYPOINT ["ntfy"]
|
||||||
|
|||||||
52
Makefile
52
Makefile
@@ -77,7 +77,19 @@ clean: .PHONY
|
|||||||
build: web docs cli
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
67
cmd/serve.go
67
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"}),
|
||||||
@@ -41,6 +49,7 @@ var flagsServe = []cli.Flag{
|
|||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
@@ -58,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",
|
||||||
@@ -67,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
|
||||||
@@ -84,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")
|
||||||
@@ -102,6 +112,7 @@ func execServe(c *cli.Context) error {
|
|||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpSenderUser := c.String("smtp-sender-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
smtpSenderPass := c.String("smtp-sender-pass")
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
@@ -147,6 +158,10 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
|
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
|
||||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||||
|
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||||
|
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||||
|
} else if upstreamBaseURL != "" && baseURL == "" {
|
||||||
|
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
webRootIsApp := webRoot == "app"
|
webRootIsApp := webRoot == "app"
|
||||||
@@ -186,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 {
|
||||||
@@ -215,6 +230,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.WebRootIsApp = webRootIsApp
|
conf.WebRootIsApp = webRootIsApp
|
||||||
|
conf.UpstreamBaseURL = upstreamBaseURL
|
||||||
conf.SMTPSenderAddr = smtpSenderAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
conf.SMTPSenderUser = smtpSenderUser
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
conf.SMTPSenderPass = smtpSenderPass
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
@@ -233,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,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
|
||||||
|
|
||||||
205
docs/config.md
205
docs/config.md
@@ -618,6 +618,43 @@ Example:
|
|||||||
firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iOS instant notifications
|
||||||
|
Unlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant
|
||||||
|
push notifications without a central server.
|
||||||
|
|
||||||
|
To still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request`
|
||||||
|
messages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app),
|
||||||
|
which will then forward it to Firebase/APNS.
|
||||||
|
|
||||||
|
To configure it, simply set `upstream-base-url` like so:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
upstream-base-url: "https://ntfy.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
||||||
|
the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||||
|
|
||||||
|
If `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours,
|
||||||
|
depending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though.
|
||||||
|
|
||||||
|
In case you're curious, here's an example of the entire flow:
|
||||||
|
|
||||||
|
- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic`
|
||||||
|
- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL)
|
||||||
|
- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a
|
||||||
|
poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server
|
||||||
|
contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic).
|
||||||
|
- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device
|
||||||
|
- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it
|
||||||
|
|
||||||
|
Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b
|
||||||
|
{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"}
|
||||||
|
```
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
@@ -671,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**.
|
||||||
@@ -770,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
|
||||||
@@ -780,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.
|
||||||
@@ -844,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]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -319,3 +319,9 @@ To build your own version with Firebase, you must:
|
|||||||
# To build a bundle .aab (app/play/release/*.aab)
|
# To build a bundle .aab (app/play/release/*.aab)
|
||||||
./gradlew bundlePlayRelease
|
./gradlew bundlePlayRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iOS app
|
||||||
|
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
I haven't had time to move the build instructions here. Please check out the repository instead.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -385,3 +385,51 @@ JSON payload. Remember to change the `https://requests.example.com` to your jell
|
|||||||
"click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}"
|
"click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
Here is an example for the configuration.yml file to setup a REST notify component.
|
||||||
|
Since Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
- name: ntfy
|
||||||
|
platform: rest
|
||||||
|
method: POST_JSON
|
||||||
|
data:
|
||||||
|
topic: YOUR_NTFY_TOPIC
|
||||||
|
title_param_name: title
|
||||||
|
message_param_name: message
|
||||||
|
resource: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to authenticate to your ntfy resource, define the authentication, username and password as below:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
- name: ntfy
|
||||||
|
platform: rest
|
||||||
|
method: POST_JSON
|
||||||
|
authentication: basic
|
||||||
|
username: YOUR_USERNAME
|
||||||
|
password: YOUR_PASSWORD
|
||||||
|
data:
|
||||||
|
topic: YOUR_NTFY_TOPIC
|
||||||
|
title_param_name: title
|
||||||
|
message_param_name: message
|
||||||
|
resource: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
- name: ntfy
|
||||||
|
platform: rest
|
||||||
|
method: POST_JSON
|
||||||
|
data:
|
||||||
|
topic: YOUR_NTFY_TOPIC
|
||||||
|
priority: 4
|
||||||
|
title_param_name: title
|
||||||
|
message_param_name: message
|
||||||
|
resource: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
|
|||||||
## Step 1: Get the app
|
## Step 1: Get the app
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://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>
|
||||||
|
|
||||||
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid.
|
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid.
|
||||||
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just
|
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just
|
||||||
@@ -83,7 +83,7 @@ This will create a notification that looks like this:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on
|
That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on
|
||||||
[publishing messages](publish.md), as well as the detailed page on the [Android app](subscribe/phone.md).
|
[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md).
|
||||||
|
|
||||||
Here's another video showing the entire process:
|
Here's another video showing the entire process:
|
||||||
|
|
||||||
|
|||||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_1.23.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_1.25.1_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_1.23.0_linux_x86_64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.25.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_1.23.0_linux_armv6.tar.gz
|
tar zxvf ntfy_1.25.1_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_1.23.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.25.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_1.23.0_linux_armv7.tar.gz
|
tar zxvf ntfy_1.25.1_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_1.23.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.25.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_1.23.0_linux_arm64.tar.gz
|
tar zxvf ntfy_1.25.1_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_1.23.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.25.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_1.23.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_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.23.0/ntfy_v1.23.0_macOS_all.tar.gz > ntfy_v1.23.0_macOS_all.tar.gz
|
curl https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_v1.25.1_macOS_all.tar.gz > ntfy_v1.25.1_macOS_all.tar.gz
|
||||||
tar zxvf ntfy_v1.23.0_macOS_all.tar.gz
|
tar zxvf ntfy_v1.25.1_macOS_all.tar.gz
|
||||||
sudo cp -a ntfy_v1.23.0_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_v1.25.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_v1.23.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_v1.25.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -200,7 +206,7 @@ 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.23.0/ntfy_v1.23.0-next_windows_x86_64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_v1.25.1_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).
|
||||||
@@ -233,17 +239,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 +261,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 +272,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.
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message title
|
## Message title
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
|
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
|
||||||
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||||
|
|
||||||
@@ -372,7 +374,9 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message priority
|
## Message priority
|
||||||
All messages have a priority, which defines how urgently your phone notifies you. You can set custom
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
|
All messages have a priority, which defines how urgently your phone notifies you. On Android, you can set custom
|
||||||
notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)).
|
notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)).
|
||||||
|
|
||||||
The following priorities exist:
|
The following priorities exist:
|
||||||
@@ -460,6 +464,8 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Tags & emojis 🥳 🎉
|
## Tags & emojis 🥳 🎉
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can tag messages with emojis and other relevant strings:
|
You can tag messages with emojis and other relevant strings:
|
||||||
|
|
||||||
* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended
|
* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended
|
||||||
@@ -579,6 +585,8 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Scheduled delivery
|
## Scheduled delivery
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
|
You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
|
||||||
reminders or even to execute commands at a later date (if your subscriber acts on messages).
|
reminders or even to execute commands at a later date (if your subscriber acts on messages).
|
||||||
|
|
||||||
@@ -679,6 +687,8 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
|
|||||||
</tr></table>
|
</tr></table>
|
||||||
|
|
||||||
## Webhooks (publish via GET)
|
## Webhooks (publish via GET)
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
|
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
|
||||||
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
|
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
|
||||||
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
|
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
|
||||||
@@ -782,6 +792,8 @@ Here's an example with a custom message, tags and a priority:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Publish as JSON
|
## Publish as JSON
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
|
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
|
||||||
adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
|
adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
|
||||||
as JSON in the request body.
|
as JSON in the request body.
|
||||||
@@ -943,6 +955,8 @@ all the supported fields:
|
|||||||
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
||||||
|
|
||||||
## Action buttons
|
## Action buttons
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly
|
You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly
|
||||||
useful and has countless applications.
|
useful and has countless applications.
|
||||||
|
|
||||||
@@ -953,7 +967,7 @@ As of today, the following actions are supported:
|
|||||||
|
|
||||||
* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped
|
* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped
|
||||||
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
||||||
when the action button is tapped
|
when the action button is tapped (only supported on Android)
|
||||||
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
||||||
|
|
||||||
Here's an example of what that a notification with actions can look like:
|
Here's an example of what that a notification with actions can look like:
|
||||||
@@ -1276,6 +1290,8 @@ The required/optional fields for each action depend on the type of the action it
|
|||||||
for details.
|
for details.
|
||||||
|
|
||||||
### Open website/app
|
### Open website/app
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or
|
The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or
|
||||||
even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your
|
even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your
|
||||||
desktop browser treat the links. Normally it'll just open a link in the browser.
|
desktop browser treat the links. Normally it'll just open a link in the browser.
|
||||||
@@ -1515,6 +1531,8 @@ The `view` action supports the following fields:
|
|||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
||||||
|
|
||||||
### Send Android broadcast
|
### Send Android broadcast
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
||||||
when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
|
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
|
||||||
@@ -1779,6 +1797,8 @@ The `broadcast` action supports the following fields:
|
|||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
||||||
|
|
||||||
### Send HTTP request
|
### Send HTTP request
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs
|
The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs
|
||||||
for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
|
for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
|
||||||
|
|
||||||
@@ -1791,14 +1811,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
```
|
```
|
||||||
curl \
|
curl \
|
||||||
-d "Garage door has been open for 15 minutes. Close it?" \
|
-d "Garage door has been open for 15 minutes. Close it?" \
|
||||||
-H "Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
-H "Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
||||||
ntfy.sh/myhome
|
ntfy.sh/myhome
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "ntfy CLI"
|
=== "ntfy CLI"
|
||||||
```
|
```
|
||||||
ntfy publish \
|
ntfy publish \
|
||||||
--actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
--actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
||||||
myhome \
|
myhome \
|
||||||
"Garage door has been open for 15 minutes. Close it?"
|
"Garage door has been open for 15 minutes. Close it?"
|
||||||
```
|
```
|
||||||
@@ -1807,7 +1827,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
``` http
|
``` http
|
||||||
POST /myhome HTTP/1.1
|
POST /myhome HTTP/1.1
|
||||||
Host: ntfy.sh
|
Host: ntfy.sh
|
||||||
Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"}
|
Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"}
|
||||||
|
|
||||||
Garage door has been open for 15 minutes. Close it?
|
Garage door has been open for 15 minutes. Close it?
|
||||||
```
|
```
|
||||||
@@ -1818,7 +1838,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'Garage door has been open for 15 minutes. Close it?',
|
body: 'Garage door has been open for 15 minutes. Close it?',
|
||||||
headers: {
|
headers: {
|
||||||
'Actions': 'http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}'
|
'Actions': 'http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -1826,14 +1846,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
=== "Go"
|
=== "Go"
|
||||||
``` go
|
``` go
|
||||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?"))
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?"))
|
||||||
req.Header.Set("Actions", "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}")
|
req.Header.Set("Actions", "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}")
|
||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh/myhome"
|
$uri = "https://ntfy.sh/myhome"
|
||||||
$headers = @{ Actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }
|
$headers = @{ Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }
|
||||||
$body = "Garage door has been open for 15 minutes. Close it?"
|
$body = "Garage door has been open for 15 minutes. Close it?"
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
```
|
```
|
||||||
@@ -1842,7 +1862,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/myhome",
|
requests.post("https://ntfy.sh/myhome",
|
||||||
data="Garage door has been open for 15 minutes. Close it?",
|
data="Garage door has been open for 15 minutes. Close it?",
|
||||||
headers={ "Actions": "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" })
|
headers={ "Actions": "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" })
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PHP"
|
=== "PHP"
|
||||||
@@ -1852,7 +1872,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
'method' => 'POST',
|
'method' => 'POST',
|
||||||
'header' =>
|
'header' =>
|
||||||
"Content-Type: text/plain\r\n" .
|
"Content-Type: text/plain\r\n" .
|
||||||
"Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}",
|
"Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}",
|
||||||
'content' => 'Garage door has been open for 15 minutes. Close it?'
|
'content' => 'Garage door has been open for 15 minutes. Close it?'
|
||||||
]
|
]
|
||||||
]));
|
]));
|
||||||
@@ -2055,6 +2075,8 @@ The `http` action supports the following fields:
|
|||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
||||||
|
|
||||||
## Click action
|
## Click action
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
||||||
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
||||||
the web browser (or the app) and open the website.
|
the web browser (or the app) and open the website.
|
||||||
@@ -2143,6 +2165,8 @@ Here's an example that will open Reddit when the notification is clicked:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
_Supported on:_ :material-android: :material-firefox:
|
||||||
|
|
||||||
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
||||||
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
||||||
|
|
||||||
@@ -2315,6 +2339,8 @@ Here's an example showing how to attach an APK file:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## E-mail notifications
|
## E-mail notifications
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
|
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
|
||||||
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
||||||
|
|
||||||
@@ -2425,6 +2451,8 @@ Here's what that looks like in Google Mail:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## E-mail publishing
|
## E-mail publishing
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
|
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
|
||||||
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
|
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
|
||||||
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
|
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||||
@@ -2754,4 +2782,5 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
|||||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
||||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
||||||
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
||||||
|
| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) |
|
||||||
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |
|
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |
|
||||||
|
|||||||
116
docs/releases.md
116
docs/releases.md
@@ -2,9 +2,125 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
## ntfy Android app v1.14.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
## ntfy server v1.25.1
|
||||||
|
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))
|
||||||
|
* Add `tzdata` to Docker image to allow overriding the timezone with `TZ` ([#307](https://github.com/binwiederhier/ntfy/pull/307), thanks to [@ksurl](https://github.com/ksurl))
|
||||||
|
|
||||||
|
**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:**
|
||||||
|
|
||||||
|
* [Message priority](https://ntfy.sh/docs/publish/#message-priority) support (no ticket)
|
||||||
|
* [Tags/emojis](https://ntfy.sh/docs/publish/#tags-emojis) support (no ticket)
|
||||||
|
* [Action buttons](https://ntfy.sh/docs/publish/#action-buttons) support (no ticket)
|
||||||
|
* [Click action](https://ntfy.sh/docs/publish/#click-action) support (no ticket)
|
||||||
|
* Open topic when notification clicked (no ticket)
|
||||||
|
* Notification now makes a sound and vibrates (no ticket)
|
||||||
|
* Cancel notifications when navigating to topic (no ticket)
|
||||||
|
* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99))
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))
|
||||||
|
|
||||||
|
## ntfy server v1.24.0
|
||||||
|
Released May 28, 2022
|
||||||
|
|
||||||
|
This release of the ntfy server brings supporting features for the ntfy iOS app. Most importantly, it
|
||||||
|
enables support for self-hosted servers in combination with the iOS app. This is to overcome the restrictive
|
||||||
|
Apple development environment.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket)
|
||||||
|
* Add subscribe filter to query exact messages by ID (no ticket)
|
||||||
|
* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket)
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||||
|
|
||||||
|
## ntfy iOS app v1.0
|
||||||
|
Released May 25, 2022
|
||||||
|
|
||||||
|
This is the first version of the ntfy iOS app. It supports only ntfy.sh (no selfhosted servers) and only messages + title
|
||||||
|
(no priority, tags, attachments, ...). I'll rapidly add (hopefully) most of the other ntfy features, and then I'll focus
|
||||||
|
on self-hosted servers.
|
||||||
|
|
||||||
|
The app is now available in the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||||
|
|
||||||
|
**Tickets:**
|
||||||
|
|
||||||
|
* iOS app ([#4](https://github.com/binwiederhier/ntfy/issues/4), see also: [TestFlight summary](https://github.com/binwiederhier/ntfy/issues/4#issuecomment-1133767150))
|
||||||
|
|
||||||
|
**Thanks:**
|
||||||
|
|
||||||
|
* Thank you to all the testers who tried out the app. You guys gave me the confidence that it's ready to release (albeit with
|
||||||
|
some known issues which will be addressed in follow-up releases).
|
||||||
|
|
||||||
## ntfy server v1.23.0
|
## ntfy server v1.23.0
|
||||||
Released May 21, 2022
|
Released May 21, 2022
|
||||||
|
|
||||||
|
This release ships a CLI for Windows and macOS, as well as the ability to disable the web app entirely. On top of that,
|
||||||
|
it adds support for APNs, the iOS messaging service. This is needed for the (soon to be released) iOS app.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
|
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
|
||||||
|
|||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Filter messages
|
### Filter messages
|
||||||
You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and
|
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
|
||||||
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
||||||
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
|
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
|
||||||
|
|
||||||
@@ -280,12 +280,13 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
|
|||||||
|
|
||||||
Available filters (all case-insensitive):
|
Available filters (all case-insensitive):
|
||||||
|
|
||||||
| Filter variable | Alias | Example | Description |
|
| Filter variable | Alias | Example | Description |
|
||||||
|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------|
|
||||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID |
|
||||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
|
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
|
||||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
|
||||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||||
|
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||||
|
|
||||||
### Subscribe to multiple topics
|
### Subscribe to multiple topics
|
||||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||||
@@ -315,18 +316,19 @@ format of the message. It's very straight forward:
|
|||||||
|
|
||||||
**Message**:
|
**Message**:
|
||||||
|
|
||||||
| Field | Required | Type | Example | Description |
|
| Field | Required | Type | Example | Description |
|
||||||
|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||||
|
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||||
|
|
||||||
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||||
|
|
||||||
@@ -416,6 +418,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
|||||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||||
|
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
|
||||||
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
||||||
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
||||||
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# Subscribe from your phone
|
# Subscribe from your phone
|
||||||
You can use the [ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive
|
You can use the ntfy [Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [iOS app](https://apps.apple.com/us/app/ntfy/id1625396347)
|
||||||
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
|
to receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available
|
||||||
Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4).
|
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
||||||
|
contribute, or [build your own](../develop.md).
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
||||||
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
|
||||||
|
|
||||||
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||||
the F-Droid flavor does not use Firebase.
|
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||||
@@ -31,6 +33,8 @@ If those screenshots are still not enough, here's a video:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message priority
|
## Message priority
|
||||||
|
_Supported on:_ :material-android: :material-apple:
|
||||||
|
|
||||||
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
|
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
|
||||||
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
||||||
|
|
||||||
@@ -59,6 +63,8 @@ setting, and other settings such as popover or notification dot:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Instant delivery
|
## Instant delivery
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
|
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
|
||||||
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
|
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
|
||||||
you'll see as a permanent notification that looks like this:
|
you'll see as a permanent notification that looks like this:
|
||||||
@@ -89,6 +95,8 @@ The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in
|
|||||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||||
|
|
||||||
## Share to topic
|
## Share to topic
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
||||||
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
||||||
you shared content to and lists them at the bottom.
|
you shared content to and lists them at the bottom.
|
||||||
@@ -101,6 +109,8 @@ The feature is pretty self-explanatory, and one picture says more than a thousan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ntfy:// links
|
## ntfy:// links
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
||||||
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
||||||
or to simply directly link to a topic from a mobile website.
|
or to simply directly link to a topic from a mobile website.
|
||||||
@@ -119,6 +129,8 @@ or to simply directly link to a topic from a mobile website.
|
|||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
### UnifiedPush
|
### UnifiedPush
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||||
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||||
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||||
@@ -134,6 +146,8 @@ to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Automation apps
|
### Automation apps
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
||||||
**react to incoming messages**, as well as **send messages**.
|
**react to incoming messages**, as well as **send messages**.
|
||||||
@@ -210,9 +224,3 @@ The following intent extras are supported when for the intent with the `io.hecke
|
|||||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||||
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
## iPhone/iOS
|
|
||||||
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
|
||||||
for ntfy, but it's in the works. You can track the status on GitHub.
|
|
||||||
|
|
||||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>
|
|
||||||
|
|||||||
26
go.mod
26
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
|
||||||
@@ -14,24 +13,25 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.13
|
github.com/mattn/go-sqlite3 v1.14.13
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/urfave/cli/v2 v2.7.1
|
github.com/urfave/cli/v2 v2.8.1
|
||||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
|
||||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // 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-20220411215600-e5f449aeb171
|
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.80.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.101.1 // 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
|
||||||
cloud.google.com/go/iam v0.3.0 // indirect
|
cloud.google.com/go/iam v0.3.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
@@ -42,14 +42,16 @@ require (
|
|||||||
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
go.opencensus.io v0.23.0 // indirect
|
go.opencensus.io v0.23.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 // 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-20220519153652-3a47de7e79bd // 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
|
||||||
)
|
)
|
||||||
|
|||||||
72
go.sum
72
go.sum
@@ -26,9 +26,10 @@ 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.101.1 h1:3+/0TAm9JD/PyhkrDWQWi2L197h3euCsM+H+J4iYTR8=
|
cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
|
||||||
cloud.google.com/go v0.101.1/go.mod h1:55HwjsGW4CHD3JrNuMdZtSDsgTs0CuCB/bBTugD+7AA=
|
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
@@ -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,12 +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.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
|
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=
|
||||||
@@ -71,8 +74,6 @@ github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
|
|||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 h1:R/qAiUxFT3mNgQaNqJe0IVznjKRNm23ohAIh9lgtlzc=
|
|
||||||
github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0/go.mod h1:v3ZDlfVAL1OrkKHbGSFFK60k0/7hruHPDq2XMs9Gu6U=
|
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
@@ -171,9 +172,8 @@ github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPg
|
|||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
|
||||||
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
|
||||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
@@ -239,8 +239,10 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/urfave/cli/v2 v2.7.1 h1:DsAOFeI9T0vmUW4LiGR5mhuCIn5kqGIE4WMU2ytmH00=
|
github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
|
||||||
github.com/urfave/cli/v2 v2.7.1/go.mod h1:TYFbtzt/azQoJOrGH5mDfZtS0jIkl/OeFwlRWPR9KRM=
|
github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -260,8 +262,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 h1:SLP7Q4Di66FONjDJbCYrCRrh97focO6sLogHO7/g8F0=
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -339,8 +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 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
|
golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
|
||||||
|
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@@ -360,8 +363,9 @@ golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ
|
|||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -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=
|
||||||
@@ -436,8 +443,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbuf
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
|
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
|
||||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -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.77.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 h1:IQWaGVCYnsm4MO3hh+WtSXMzMzuyFx/fuR8qkN3A0Qo=
|
|
||||||
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.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw=
|
||||||
|
google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -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,24 +636,30 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
|
|||||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
||||||
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
|
||||||
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 h1:e0TwkXOdbnH/1x5rc5MZ/VYyiZ4v+RdVfrGMqEwT68I=
|
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||||
|
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
|
||||||
|
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ markdown_extensions:
|
|||||||
custom_checkbox: true
|
custom_checkbox: true
|
||||||
- attr_list
|
- attr_list
|
||||||
- md_in_html
|
- md_in_html
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
|
|||||||
@@ -6,14 +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 // 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)
|
||||||
|
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
|
||||||
@@ -65,8 +67,11 @@ 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
|
||||||
|
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||||
|
UpstreamBaseURL string
|
||||||
SMTPSenderAddr string
|
SMTPSenderAddr string
|
||||||
SMTPSenderUser string
|
SMTPSenderUser string
|
||||||
SMTPSenderPass string
|
SMTPSenderPass string
|
||||||
@@ -115,8 +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,
|
||||||
|
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
|
||||||
|
|||||||
368
server/server.go
368
server/server.go
@@ -3,16 +3,15 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -33,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
|
||||||
@@ -91,7 +90,9 @@ var (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
firebaseControlTopic = "~control" // See Android if changed
|
firebaseControlTopic = "~control" // See Android if changed
|
||||||
|
firebasePollTopic = "~poll" // See iOS if changed
|
||||||
emptyMessageBody = "triggered" // Used if message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
|
newMessageBody = "New message" // Used in poll requests as generic message
|
||||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||||
encodingBase64 = "base64"
|
encodingBase64 = "base64"
|
||||||
)
|
)
|
||||||
@@ -133,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,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)
|
||||||
@@ -218,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
|
||||||
@@ -245,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)
|
||||||
@@ -421,6 +433,9 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if m.PollID != "" {
|
||||||
|
m = newPollRequestMessage(t.ID, m.PollID)
|
||||||
|
}
|
||||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -428,24 +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 func() {
|
}
|
||||||
if err := s.firebase(m); err != nil {
|
if s.smtpSender != nil && email != "" {
|
||||||
log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
|
go s.sendEmail(v, m, email)
|
||||||
}
|
}
|
||||||
}()
|
if s.config.UpstreamBaseURL != "" {
|
||||||
}
|
go s.forwardPollRequest(v, m)
|
||||||
if s.mailer != nil && email != "" && !delayed {
|
}
|
||||||
go func() {
|
} else {
|
||||||
if err := s.mailer.Send(v.ip, email, m); err != nil {
|
log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m))
|
||||||
log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
if cache {
|
if cache {
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
@@ -463,6 +480,48 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||||
|
log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
|
||||||
|
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||||
|
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) {
|
||||||
|
log.Debug("%s Sending email to %s", logMessagePrefix(v, m), email)
|
||||||
|
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||||
|
log.Warn("%s Unable to send email to %s: %v", logMessagePrefix(v, m), email, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||||
|
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
||||||
|
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
|
||||||
|
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
|
||||||
|
log.Debug("%s Publishing poll request to %s", logMessagePrefix(v, m), forwardURL)
|
||||||
|
req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Poll-ID", m.ID)
|
||||||
|
var httpClient = &http.Client{
|
||||||
|
Timeout: time.Second * 10,
|
||||||
|
}
|
||||||
|
response, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
|
||||||
|
return
|
||||||
|
} else if response.StatusCode != http.StatusOK {
|
||||||
|
log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logMessagePrefix(v, m), response.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
@@ -500,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")
|
||||||
@@ -535,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 != "" {
|
||||||
@@ -548,32 +608,48 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
|||||||
firebase = false
|
firebase = false
|
||||||
unifiedpush = true
|
unifiedpush = true
|
||||||
}
|
}
|
||||||
|
m.PollID = readParam(r, "x-poll-id", "poll-id")
|
||||||
|
if m.PollID != "" {
|
||||||
|
unifiedpush = false
|
||||||
|
cache = false
|
||||||
|
email = ""
|
||||||
|
}
|
||||||
return cache, firebase, email, unifiedpush, nil
|
return cache, firebase, email, unifiedpush, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||||
//
|
//
|
||||||
// 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
||||||
|
// If a message is flagged as poll request, the body does not matter and is discarded
|
||||||
|
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||||
// If body is binary, encode as base64, if not do not encode
|
// If body is binary, encode as base64, if not do not encode
|
||||||
// 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||||
// Body must be a message, because we attached an external URL
|
// Body must be a message, because we attached an external URL
|
||||||
// 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||||
// Body must be attachment, because we passed a filename
|
// Body must be attachment, because we passed a filename
|
||||||
// 4. curl -T file.txt ntfy.sh/mytopic
|
|
||||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
|
||||||
// 5. curl -T file.txt ntfy.sh/mytopic
|
// 5. curl -T file.txt ntfy.sh/mytopic
|
||||||
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
|
// 6. curl -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is > message limit, treat it as an attachment
|
// If file.txt is > message limit, treat it as an attachment
|
||||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
||||||
if unifiedpush {
|
if m.Event == pollRequestEvent { // Case 1
|
||||||
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
|
return s.handleBodyDiscard(body)
|
||||||
|
} else if unifiedpush {
|
||||||
|
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
|
||||||
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 2
|
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 4
|
return s.handleBodyAsTextMessage(m, body) // Case 5
|
||||||
}
|
}
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
|
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 {
|
||||||
@@ -620,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)
|
||||||
@@ -675,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
|
||||||
}
|
}
|
||||||
@@ -688,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
|
||||||
}
|
}
|
||||||
@@ -709,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 {
|
||||||
@@ -720,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 {
|
||||||
@@ -731,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,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
|
||||||
@@ -776,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 {
|
||||||
@@ -792,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 {
|
||||||
@@ -806,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
|
||||||
}
|
}
|
||||||
@@ -819,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 {
|
||||||
@@ -830,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
|
||||||
}
|
}
|
||||||
@@ -857,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
|
||||||
}
|
}
|
||||||
@@ -867,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -961,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
|
||||||
@@ -991,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 {
|
||||||
@@ -1003,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
|
||||||
@@ -1053,28 +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: %s", err.Error())
|
log.Warn("Error sending delayed messages: %s", err.Error())
|
||||||
}
|
}
|
||||||
case <-s.closeChan:
|
case <-s.closeChan:
|
||||||
return
|
return
|
||||||
@@ -1083,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
|
||||||
}
|
}
|
||||||
@@ -1205,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1243,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 {
|
||||||
@@ -1253,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)
|
||||||
|
|||||||
@@ -135,6 +135,18 @@
|
|||||||
#
|
#
|
||||||
# web-root: app
|
# web-root: app
|
||||||
|
|
||||||
|
# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
|
||||||
|
#
|
||||||
|
# iOS users:
|
||||||
|
# If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this:
|
||||||
|
# upstream-base-url: "https://ntfy.sh"
|
||||||
|
#
|
||||||
|
# If set, all incoming messages will publish a "poll_request" message to the configured upstream server, containing
|
||||||
|
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||||
|
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
||||||
|
#
|
||||||
|
# upstream-base-url:
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
#
|
#
|
||||||
# global-topic-limit: 15000
|
# global-topic-limit: 15000
|
||||||
@@ -166,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,58 +19,99 @@ const (
|
|||||||
fcmApnsBodyMessageLimit = 100
|
fcmApnsBodyMessageLimit = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
var (
|
||||||
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
|
errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic")
|
||||||
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
|
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
|
||||||
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
)
|
||||||
s, err := json.Marshal(m)
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return m
|
return err
|
||||||
}
|
}
|
||||||
if len(s) > fcmMessageLimit {
|
if log.IsTrace() {
|
||||||
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
|
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm))
|
||||||
message, ok := m.Data["message"]
|
|
||||||
if ok && len(message) > over {
|
|
||||||
m.Data["truncated"] = "1"
|
|
||||||
m.Data["message"] = message[:len(message)-over]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return m
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeTruncateAPNSBodyMessage truncates the body for APNS.
|
// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging.
|
||||||
//
|
// In tests, this can be implemented with a mock.
|
||||||
// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
|
type firebaseSender interface {
|
||||||
// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
|
// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded
|
||||||
// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
|
// if a rate limit has reached.
|
||||||
// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
|
Send(m *messaging.Message) error
|
||||||
func maybeTruncateAPNSBodyMessage(s string) string {
|
|
||||||
if len(s) >= fcmApnsBodyMessageLimit {
|
|
||||||
over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
|
|
||||||
return s[:len(s)-over] + "..."
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, 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.
|
||||||
|
//
|
||||||
|
// Normal messages ("message"):
|
||||||
|
// - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields
|
||||||
|
// in the "data" attribute. In the Android app, we then turn those into a notification and display it.
|
||||||
|
// - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and
|
||||||
|
// message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the
|
||||||
|
// message in the Notification Service Extension.
|
||||||
|
//
|
||||||
|
// Keepalive messages ("keepalive"):
|
||||||
|
// - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died,
|
||||||
|
// e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).
|
||||||
|
// - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS
|
||||||
|
// does not allow any background or scheduled activity at all.
|
||||||
|
//
|
||||||
|
// Poll request messages ("poll_request"):
|
||||||
|
// - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.
|
||||||
|
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
||||||
|
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
||||||
|
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
||||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
||||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||||
var apnsConfig *messaging.APNSConfig
|
var apnsConfig *messaging.APNSConfig
|
||||||
@@ -80,6 +123,17 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
|||||||
"event": m.Event,
|
"event": m.Event,
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
}
|
}
|
||||||
|
apnsConfig = createAPNSBackgroundConfig(data)
|
||||||
|
case pollRequestEvent:
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
"message": m.Message,
|
||||||
|
"poll_id": m.PollID,
|
||||||
|
}
|
||||||
|
apnsConfig = createAPNSAlertConfig(m, data)
|
||||||
case messageEvent:
|
case messageEvent:
|
||||||
allowForward := true
|
allowForward := true
|
||||||
if auther != nil {
|
if auther != nil {
|
||||||
@@ -112,22 +166,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
|||||||
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
||||||
data["attachment_url"] = m.Attachment.URL
|
data["attachment_url"] = m.Attachment.URL
|
||||||
}
|
}
|
||||||
apnsData := make(map[string]interface{})
|
apnsConfig = createAPNSAlertConfig(m, data)
|
||||||
for k, v := range data {
|
|
||||||
apnsData[k] = v
|
|
||||||
}
|
|
||||||
apnsConfig = &messaging.APNSConfig{
|
|
||||||
Payload: &messaging.APNSPayload{
|
|
||||||
CustomData: apnsData,
|
|
||||||
Aps: &messaging.Aps{
|
|
||||||
MutableContent: true,
|
|
||||||
Alert: &messaging.ApsAlert{
|
|
||||||
Title: m.Title,
|
|
||||||
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// If anonymous read for a topic is not allowed, we cannot send the message along
|
// If anonymous read for a topic is not allowed, we cannot send the message along
|
||||||
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
||||||
@@ -137,6 +176,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
|||||||
"event": pollRequestEvent,
|
"event": pollRequestEvent,
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
}
|
}
|
||||||
|
// TODO Handle APNS?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var androidConfig *messaging.AndroidConfig
|
var androidConfig *messaging.AndroidConfig
|
||||||
@@ -152,3 +192,82 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
|||||||
APNS: apnsConfig,
|
APNS: apnsConfig,
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
||||||
|
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
|
||||||
|
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
|
||||||
|
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||||
|
s, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if len(s) > fcmMessageLimit {
|
||||||
|
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
|
||||||
|
message, ok := m.Data["message"]
|
||||||
|
if ok && len(message) > over {
|
||||||
|
m.Data["truncated"] = "1"
|
||||||
|
m.Data["message"] = message[:len(message)-over]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
|
||||||
|
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
|
||||||
|
// Extension in iOS can modify the message.
|
||||||
|
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
|
||||||
|
apnsData := make(map[string]interface{})
|
||||||
|
for k, v := range data {
|
||||||
|
apnsData[k] = v
|
||||||
|
}
|
||||||
|
return &messaging.APNSConfig{
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
CustomData: apnsData,
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
MutableContent: true,
|
||||||
|
Alert: &messaging.ApsAlert{
|
||||||
|
Title: m.Title,
|
||||||
|
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only
|
||||||
|
// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll
|
||||||
|
// topic, which triggers the iOS app to poll all topics for changes.
|
||||||
|
//
|
||||||
|
// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
|
||||||
|
func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
|
||||||
|
apnsData := make(map[string]interface{})
|
||||||
|
for k, v := range data {
|
||||||
|
apnsData[k] = v
|
||||||
|
}
|
||||||
|
return &messaging.APNSConfig{
|
||||||
|
Headers: map[string]string{
|
||||||
|
"apns-push-type": "background",
|
||||||
|
"apns-priority": "5",
|
||||||
|
},
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
ContentAvailable: true,
|
||||||
|
},
|
||||||
|
CustomData: apnsData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeTruncateAPNSBodyMessage truncates the body for APNS.
|
||||||
|
//
|
||||||
|
// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
|
||||||
|
// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
|
||||||
|
// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
|
||||||
|
// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
|
||||||
|
func maybeTruncateAPNSBodyMessage(s string) string {
|
||||||
|
if len(s) >= fcmApnsBodyMessageLimit {
|
||||||
|
over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
|
||||||
|
return s[:len(s)-over] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,12 +27,58 @@ func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
|
|||||||
return errors.New("unauthorized")
|
return errors.New("unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testFirebaseSender struct {
|
||||||
|
allowed int
|
||||||
|
messages []*messaging.Message
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestFirebaseSender(allowed int) *testFirebaseSender {
|
||||||
|
return &testFirebaseSender{
|
||||||
|
allowed: allowed,
|
||||||
|
messages: make([]*messaging.Message, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testFirebaseSender) Send(m *messaging.Message) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.messages)+1 > s.allowed {
|
||||||
|
return errFirebaseQuotaExceeded
|
||||||
|
}
|
||||||
|
s.messages = append(s.messages, m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testFirebaseSender) Messages() []*messaging.Message {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append(make([]*messaging.Message, 0), s.messages...)
|
||||||
|
}
|
||||||
|
|
||||||
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
||||||
m := newKeepaliveMessage("mytopic")
|
m := newKeepaliveMessage("mytopic")
|
||||||
fbm, err := toFirebaseMessage(m, nil)
|
fbm, err := toFirebaseMessage(m, nil)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "mytopic", fbm.Topic)
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
require.Nil(t, fbm.Android)
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Headers: map[string]string{
|
||||||
|
"apns-push-type": "background",
|
||||||
|
"apns-priority": "5",
|
||||||
|
},
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
ContentAvailable: true,
|
||||||
|
},
|
||||||
|
CustomData: map[string]interface{}{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
require.Equal(t, map[string]string{
|
require.Equal(t, map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
@@ -46,6 +93,23 @@ func TestToFirebaseMessage_Open(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "mytopic", fbm.Topic)
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
require.Nil(t, fbm.Android)
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Headers: map[string]string{
|
||||||
|
"apns-push-type": "background",
|
||||||
|
"apns-priority": "5",
|
||||||
|
},
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
ContentAvailable: true,
|
||||||
|
},
|
||||||
|
CustomData: map[string]interface{}{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
require.Equal(t, map[string]string{
|
require.Equal(t, map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
@@ -60,13 +124,31 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
m.Tags = []string{"tag 1", "tag2"}
|
m.Tags = []string{"tag 1", "tag2"}
|
||||||
m.Click = "https://google.com"
|
m.Click = "https://google.com"
|
||||||
m.Title = "some title"
|
m.Title = "some title"
|
||||||
|
m.Actions = []*action{
|
||||||
|
{
|
||||||
|
ID: "123",
|
||||||
|
Action: "view",
|
||||||
|
Label: "Open page",
|
||||||
|
Clear: true,
|
||||||
|
URL: "https://ntfy.sh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "456",
|
||||||
|
Action: "http",
|
||||||
|
Label: "Close door",
|
||||||
|
URL: "https://door.com/close",
|
||||||
|
Method: "PUT",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"really": "yes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "some file.jpg",
|
Name: "some file.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
Size: 12345,
|
Size: 12345,
|
||||||
Expires: 98765543,
|
Expires: 98765543,
|
||||||
URL: "https://example.com/file.jpg",
|
URL: "https://example.com/file.jpg",
|
||||||
Owner: "some-owner",
|
|
||||||
}
|
}
|
||||||
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -74,6 +156,35 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
require.Equal(t, &messaging.AndroidConfig{
|
require.Equal(t, &messaging.AndroidConfig{
|
||||||
Priority: "high",
|
Priority: "high",
|
||||||
}, fbm.Android)
|
}, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
MutableContent: true,
|
||||||
|
Alert: &messaging.ApsAlert{
|
||||||
|
Title: "some title",
|
||||||
|
Body: "this is a message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CustomData: map[string]interface{}{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"priority": "4",
|
||||||
|
"tags": strings.Join(m.Tags, ","),
|
||||||
|
"click": "https://google.com",
|
||||||
|
"title": "some title",
|
||||||
|
"message": "this is a message",
|
||||||
|
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||||
|
"encoding": "",
|
||||||
|
"attachment_name": "some file.jpg",
|
||||||
|
"attachment_type": "image/jpeg",
|
||||||
|
"attachment_size": "12345",
|
||||||
|
"attachment_expires": "98765543",
|
||||||
|
"attachment_url": "https://example.com/file.jpg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
require.Equal(t, map[string]string{
|
require.Equal(t, map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
@@ -84,6 +195,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
"click": "https://google.com",
|
"click": "https://google.com",
|
||||||
"title": "some title",
|
"title": "some title",
|
||||||
"message": "this is a message",
|
"message": "this is a message",
|
||||||
|
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||||
"encoding": "",
|
"encoding": "",
|
||||||
"attachment_name": "some file.jpg",
|
"attachment_name": "some file.jpg",
|
||||||
"attachment_type": "image/jpeg",
|
"attachment_type": "image/jpeg",
|
||||||
@@ -112,6 +224,41 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
|||||||
}, fbm.Data)
|
}, fbm.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||||
|
m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
|
||||||
|
fbm, err := toFirebaseMessage(m, nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
MutableContent: true,
|
||||||
|
Alert: &messaging.ApsAlert{
|
||||||
|
Title: "",
|
||||||
|
Body: "New message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CustomData: map[string]interface{}{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "poll_request",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "New message",
|
||||||
|
"poll_id": "fOv6k1QbCzo6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "poll_request",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "New message",
|
||||||
|
"poll_id": "fOv6k1QbCzo6",
|
||||||
|
}, fbm.Data)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaybeTruncateFCMMessage(t *testing.T) {
|
func TestMaybeTruncateFCMMessage(t *testing.T) {
|
||||||
origMessage := strings.Repeat("this is a long string", 300)
|
origMessage := strings.Repeat("this is a long string", 300)
|
||||||
origFCMMessage := &messaging.Message{
|
origFCMMessage := &messaging.Message{
|
||||||
@@ -168,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,43 +196,56 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
|||||||
s.backend.mu.Lock()
|
s.backend.mu.Lock()
|
||||||
defer s.backend.mu.Unlock()
|
defer s.backend.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Almost all of these errors are parse errors, and user input errors.
|
||||||
|
// We do not want to spam the log with WARN messages.
|
||||||
|
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
|
||||||
s.backend.failure++
|
s.backend.failure++
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMailBody(msg *mail.Message) (string, error) {
|
func readMailBody(msg *mail.Message) (string, error) {
|
||||||
|
if msg.Header.Get("Content-Type") == "" {
|
||||||
|
return readPlainTextMailBody(msg)
|
||||||
|
}
|
||||||
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if contentType == "text/plain" {
|
if contentType == "text/plain" {
|
||||||
body, err := io.ReadAll(msg.Body)
|
return readPlainTextMailBody(msg)
|
||||||
|
} else if strings.HasPrefix(contentType, "multipart/") {
|
||||||
|
return readMultipartMailBody(msg, params)
|
||||||
|
}
|
||||||
|
return "", errUnsupportedContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPlainTextMailBody(msg *mail.Message) (string, error) {
|
||||||
|
body, err := io.ReadAll(msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
|
||||||
|
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil { // may be io.EOF
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if partContentType != "text/plain" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(contentType, "multipart/") {
|
|
||||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
|
||||||
for {
|
|
||||||
part, err := mr.NextPart()
|
|
||||||
if err != nil { // may be io.EOF
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if partContentType != "text/plain" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(part)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(body), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", errUnsupportedContentType
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,30 @@ 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.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
||||||
|
email := `Subject: Very short mail
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
|
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
||||||
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
@@ -103,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)))
|
||||||
@@ -122,7 +138,7 @@ To: mytopic@ntfy.sh
|
|||||||
Content-Type: text/plain; charset="UTF-8"
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
you know this is a string.
|
you know this is a string.
|
||||||
it's a long string.
|
it's a long string.
|
||||||
it's supposed to be longer than the max message length
|
it's supposed to be longer than the max message length
|
||||||
which is 4096 bytes,
|
which is 4096 bytes,
|
||||||
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
@@ -186,9 +202,9 @@ 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
|
||||||
which is 4096 bytes,
|
which is 4096 bytes,
|
||||||
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
@@ -248,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)))
|
||||||
@@ -270,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
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ type message struct {
|
|||||||
Time int64 `json:"time"` // Unix time in seconds
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
Event string `json:"event"` // One of the above
|
Event string `json:"event"` // One of the above
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
Priority int `json:"priority,omitempty"`
|
Priority int `json:"priority,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Click string `json:"click,omitempty"`
|
Click string `json:"click,omitempty"`
|
||||||
Actions []*action `json:"actions,omitempty"`
|
Actions []*action `json:"actions,omitempty"`
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
PollID string `json:"poll_id,omitempty"`
|
||||||
Message string `json:"message,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,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 {
|
||||||
@@ -84,14 +85,11 @@ type messageEncoder func(msg *message) (string, error)
|
|||||||
// newMessage creates a new message with the current timestamp
|
// newMessage creates a new message with the current timestamp
|
||||||
func newMessage(event, topic, msg string) *message {
|
func newMessage(event, topic, msg string) *message {
|
||||||
return &message{
|
return &message{
|
||||||
ID: util.RandomString(messageIDLength),
|
ID: util.RandomString(messageIDLength),
|
||||||
Time: time.Now().Unix(),
|
Time: time.Now().Unix(),
|
||||||
Event: event,
|
Event: event,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Priority: 0,
|
Message: msg,
|
||||||
Tags: nil,
|
|
||||||
Title: "",
|
|
||||||
Message: msg,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +108,13 @@ func newDefaultMessage(topic, msg string) *message {
|
|||||||
return newMessage(messageEvent, topic, msg)
|
return newMessage(messageEvent, topic, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPollRequestMessage is a convenience method to create a poll request message
|
||||||
|
func newPollRequestMessage(topic, pollID string) *message {
|
||||||
|
m := newMessage(pollRequestEvent, topic, newMessageBody)
|
||||||
|
m.PollID = pollID
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
func validMessageID(s string) bool {
|
func validMessageID(s string) bool {
|
||||||
return util.ValidRandomString(s, messageIDLength)
|
return util.ValidRandomString(s, messageIDLength)
|
||||||
}
|
}
|
||||||
@@ -153,6 +158,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type queryFilter struct {
|
type queryFilter struct {
|
||||||
|
ID string
|
||||||
Message string
|
Message string
|
||||||
Title string
|
Title string
|
||||||
Tags []string
|
Tags []string
|
||||||
@@ -160,6 +166,7 @@ type queryFilter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||||
|
idFilter := readParam(r, "x-id", "id")
|
||||||
messageFilter := readParam(r, "x-message", "message", "m")
|
messageFilter := readParam(r, "x-message", "message", "m")
|
||||||
titleFilter := readParam(r, "x-title", "title", "t")
|
titleFilter := readParam(r, "x-title", "title", "t")
|
||||||
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
|
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
|
||||||
@@ -172,6 +179,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
|||||||
priorityFilter = append(priorityFilter, priority)
|
priorityFilter = append(priorityFilter, priority)
|
||||||
}
|
}
|
||||||
return &queryFilter{
|
return &queryFilter{
|
||||||
|
ID: idFilter,
|
||||||
Message: messageFilter,
|
Message: messageFilter,
|
||||||
Title: titleFilter,
|
Title: titleFilter,
|
||||||
Tags: tagsFilter,
|
Tags: tagsFilter,
|
||||||
@@ -182,11 +190,11 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
|||||||
func (q *queryFilter) Pass(msg *message) bool {
|
func (q *queryFilter) Pass(msg *message) bool {
|
||||||
if msg.Event != messageEvent {
|
if msg.Event != messageEvent {
|
||||||
return true // filters only apply to messages
|
return true // filters only apply to messages
|
||||||
}
|
} else if q.ID != "" && msg.ID != q.ID {
|
||||||
if q.Message != "" && msg.Message != q.Message {
|
|
||||||
return false
|
return false
|
||||||
}
|
} else if q.Message != "" && msg.Message != q.Message {
|
||||||
if q.Title != "" && msg.Title != q.Title {
|
return false
|
||||||
|
} else if q.Title != "" && msg.Title != q.Title {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
messagePriority := msg.Priority
|
messagePriority := msg.Priority
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
1533
web/package-lock.json
generated
1533
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:
|
||||||
|
|||||||
191
web/public/static/langs/it.json
Normal file
191
web/public/static/langs/it.json
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
"action_bar_logo_alt": "logo ntfy",
|
||||||
|
"action_bar_settings": "Impostazioni",
|
||||||
|
"action_bar_clear_notifications": "Cancella tutte le notifiche",
|
||||||
|
"action_bar_unsubscribe": "Annulla l'iscrizione",
|
||||||
|
"action_bar_toggle_action_menu": "Apri/chiudi il menu delle azioni",
|
||||||
|
"message_bar_type_message": "Digita un messaggio qui",
|
||||||
|
"message_bar_error_publishing": "Errore durante la pubblicazione della notifica",
|
||||||
|
"message_bar_show_dialog": "Mostra la finestra di dialogo di pubblicazione",
|
||||||
|
"message_bar_publish": "Pubblica messaggio",
|
||||||
|
"nav_topics_title": "Topic a cui si è iscritti",
|
||||||
|
"nav_button_all_notifications": "Tutte le notifiche",
|
||||||
|
"nav_button_settings": "Impostazioni",
|
||||||
|
"nav_button_publish_message": "Pubblica notifica",
|
||||||
|
"nav_button_subscribe": "Iscriviti al topic",
|
||||||
|
"nav_button_muted": "Notifiche disattivate",
|
||||||
|
"nav_button_connecting": "connessione",
|
||||||
|
"alert_grant_title": "Le notifiche sono disabilitate",
|
||||||
|
"alert_grant_button": "Concedi ora",
|
||||||
|
"notifications_list": "Elenco notifiche",
|
||||||
|
"notifications_list_item": "Notifiche",
|
||||||
|
"notifications_mark_read": "Segna come letto",
|
||||||
|
"notifications_delete": "Elimina",
|
||||||
|
"notifications_copied_to_clipboard": "Copiato negli appunti",
|
||||||
|
"notifications_tags": "Tags",
|
||||||
|
"notifications_priority_x": "Priorità {{priority}}",
|
||||||
|
"notifications_new_indicator": "Nuova notifica",
|
||||||
|
"notifications_attachment_image": "Immagine allegata",
|
||||||
|
"notifications_attachment_copy_url_title": "Copia l'URL dell'allegato negli appunti",
|
||||||
|
"notifications_attachment_copy_url_button": "Copia URL",
|
||||||
|
"notifications_attachment_open_title": "Vai a {{url}}",
|
||||||
|
"notifications_attachment_open_button": "Apri allegato",
|
||||||
|
"notifications_attachment_link_expires": "Il collegamento scade il {{date}}",
|
||||||
|
"notifications_attachment_link_expired": "link per il download scaduto",
|
||||||
|
"notifications_attachment_file_image": "file immagine",
|
||||||
|
"notifications_attachment_file_video": "file video",
|
||||||
|
"action_bar_toggle_mute": "Abilita/disabilita le notifiche",
|
||||||
|
"notifications_attachment_file_document": "altro documento",
|
||||||
|
"notifications_click_copy_url_button": "Copia link",
|
||||||
|
"notifications_click_open_button": "Apri link",
|
||||||
|
"notifications_actions_open_url_title": "Vai a {{url}}",
|
||||||
|
"notifications_actions_not_supported": "Azione non supportata nell'app Web",
|
||||||
|
"notifications_none_for_topic_title": "Non hai ancora ricevuto alcuna notifica per questo topic.",
|
||||||
|
"notifications_none_for_topic_description": "Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL del topic.",
|
||||||
|
"notifications_none_for_any_title": "Non hai ricevuto alcuna notifica.",
|
||||||
|
"notifications_no_subscriptions_title": "Sembra che tu non abbia ancora abbonamenti.",
|
||||||
|
"notifications_example": "Esempio",
|
||||||
|
"notifications_more_details": "Per ulteriori informazioni, consulta il <websiteLink>sito web</websiteLink> o <docsLink>documentazione</docsLink>.",
|
||||||
|
"notifications_loading": "Caricamento notifiche in corso…",
|
||||||
|
"publish_dialog_title_topic": "Pubblica su {{topic}}",
|
||||||
|
"publish_dialog_title_no_topic": "Pubblica notifica",
|
||||||
|
"publish_dialog_progress_uploading": "Caricamento in corso…",
|
||||||
|
"publish_dialog_progress_uploading_detail": "Caricamento {{loaded}}/{{total}} ({{percent}}%)…",
|
||||||
|
"publish_dialog_message_published": "Notifica pubblicata",
|
||||||
|
"publish_dialog_attachment_limits_file_and_quota_reached": "supera {{fileSizeLimit}} limite di file e quota, {{remainingBytes}} rimanenti",
|
||||||
|
"publish_dialog_attachment_limits_file_reached": "supera di {{fileSizeLimit}} il limite dei file",
|
||||||
|
"publish_dialog_attachment_limits_quota_reached": "supera la quota, {{remainingBytes}} rimanenti",
|
||||||
|
"publish_dialog_emoji_picker_show": "Scegli emoji",
|
||||||
|
"publish_dialog_priority_min": "Min. priorità",
|
||||||
|
"publish_dialog_priority_low": "Bassa priorità",
|
||||||
|
"publish_dialog_priority_default": "Priorità predefinita",
|
||||||
|
"publish_dialog_priority_high": "Priorità alta",
|
||||||
|
"publish_dialog_priority_max": "Max. priorità",
|
||||||
|
"publish_dialog_base_url_label": "URL del servizio",
|
||||||
|
"publish_dialog_base_url_placeholder": "URL del servizio, ad es. https://esempio.com",
|
||||||
|
"publish_dialog_topic_label": "Nome topic",
|
||||||
|
"publish_dialog_topic_placeholder": "Nome topic, ad es. avvisi_di_phil",
|
||||||
|
"publish_dialog_topic_reset": "Reset topic",
|
||||||
|
"publish_dialog_title_label": "Titolo",
|
||||||
|
"publish_dialog_title_placeholder": "Titolo della notifica, ad es. Avviso di spazio su disco",
|
||||||
|
"publish_dialog_message_label": "Messaggio",
|
||||||
|
"publish_dialog_message_placeholder": "Digita un messaggio qui",
|
||||||
|
"publish_dialog_tags_label": "Tags",
|
||||||
|
"publish_dialog_priority_label": "Priorità",
|
||||||
|
"publish_dialog_click_label": "Clicca URL",
|
||||||
|
"publish_dialog_click_reset": "Rimuovi l'URL del clic",
|
||||||
|
"publish_dialog_email_label": "Email",
|
||||||
|
"publish_dialog_email_placeholder": "Indirizzo a cui inoltrare la notifica, ad es. phil@example.com",
|
||||||
|
"publish_dialog_email_reset": "Rimuovi inoltro email",
|
||||||
|
"publish_dialog_attach_label": "URL Allegato",
|
||||||
|
"publish_dialog_attach_reset": "Rimuovi l'URL dell'allegato",
|
||||||
|
"publish_dialog_filename_label": "Nome del file",
|
||||||
|
"publish_dialog_filename_placeholder": "Nome file allegato",
|
||||||
|
"publish_dialog_delay_placeholder": "Consegna ritardata, ad es. {{unixTimestamp}}, {{relativeTime}} o \"{{naturalLanguage}}\" (solo in inglese)",
|
||||||
|
"publish_dialog_delay_reset": "Rimuovere la consegna ritardata",
|
||||||
|
"publish_dialog_other_features": "Altre funzionalità:",
|
||||||
|
"publish_dialog_chip_click_label": "Fare clic su URL",
|
||||||
|
"publish_dialog_chip_email_label": "Inoltra a e-mail",
|
||||||
|
"publish_dialog_chip_attach_url_label": "Allega il file tramite URL",
|
||||||
|
"publish_dialog_chip_attach_file_label": "Allega file locale",
|
||||||
|
"publish_dialog_chip_delay_label": "Ritardo nella consegna",
|
||||||
|
"publish_dialog_button_cancel_sending": "Annulla l'invio",
|
||||||
|
"publish_dialog_button_cancel": "Annulla",
|
||||||
|
"publish_dialog_button_send": "Invia",
|
||||||
|
"publish_dialog_checkbox_publish_another": "Pubblica un altro",
|
||||||
|
"publish_dialog_attached_file_title": "File allegato:",
|
||||||
|
"publish_dialog_attached_file_remove": "Rimuovi il file allegato",
|
||||||
|
"publish_dialog_drop_file_here": "Trascina il file qui",
|
||||||
|
"emoji_picker_search_clear": "Cancella ricerca",
|
||||||
|
"subscribe_dialog_subscribe_title": "Iscriviti al topic",
|
||||||
|
"subscribe_dialog_subscribe_topic_placeholder": "Nome dell'argomento, ad es. avvisi_di_phil",
|
||||||
|
"subscribe_dialog_subscribe_base_url_label": "URL del servizio",
|
||||||
|
"subscribe_dialog_subscribe_button_cancel": "Annulla",
|
||||||
|
"subscribe_dialog_login_title": "Accesso richiesto",
|
||||||
|
"subscribe_dialog_login_username_label": "Nome utente, ad es. phil",
|
||||||
|
"subscribe_dialog_login_button_login": "Login",
|
||||||
|
"subscribe_dialog_error_user_anonymous": "anonimo",
|
||||||
|
"prefs_notifications_sound_title": "Suono di notifica",
|
||||||
|
"prefs_notifications_sound_description_some": "Le notifiche riproducono il suono {{sound}} quando arrivano",
|
||||||
|
"prefs_notifications_sound_no_sound": "Nessun suono",
|
||||||
|
"prefs_notifications_min_priority_description_any": "Visualizzazione di tutte le notifiche, indipendentemente dalla priorità",
|
||||||
|
"prefs_notifications_min_priority_description_max": "Mostra notifiche se la priorità è 5 (max)",
|
||||||
|
"prefs_notifications_min_priority_any": "Qualsiasi priorità",
|
||||||
|
"prefs_notifications_min_priority_low_and_higher": "Priorità bassa e superiore",
|
||||||
|
"prefs_notifications_min_priority_high_and_higher": "Priorità alta e superiore",
|
||||||
|
"prefs_notifications_min_priority_max_only": "Solo priorità massima",
|
||||||
|
"prefs_notifications_delete_after_never": "Mai",
|
||||||
|
"prefs_notifications_delete_after_three_hours": "Dopo tre ore",
|
||||||
|
"prefs_notifications_delete_after_one_day": "Dopo un giorno",
|
||||||
|
"prefs_notifications_delete_after_never_description": "Le notifiche non vengono mai eliminate automaticamente",
|
||||||
|
"prefs_notifications_delete_after_one_day_description": "Le notifiche vengono eliminate automaticamente dopo un giorno",
|
||||||
|
"prefs_notifications_delete_after_one_week_description": "Le notifiche vengono eliminate automaticamente dopo una settimana",
|
||||||
|
"prefs_notifications_delete_after_one_month_description": "Le notifiche vengono eliminate automaticamente dopo un mese",
|
||||||
|
"prefs_users_title": "Gestisci gli utenti",
|
||||||
|
"prefs_users_description": "Aggiungi/rimuovi utenti per i tuoi topic protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.",
|
||||||
|
"prefs_users_table": "Tabella utenti",
|
||||||
|
"prefs_users_add_button": "Aggiungi utente",
|
||||||
|
"prefs_users_edit_button": "Modifica utente",
|
||||||
|
"prefs_users_delete_button": "Elimina utente",
|
||||||
|
"prefs_users_table_user_header": "Utente",
|
||||||
|
"prefs_users_table_base_url_header": "URL del servizio",
|
||||||
|
"prefs_users_dialog_title_add": "Aggiungi utente",
|
||||||
|
"prefs_users_dialog_title_edit": "Modifica utente",
|
||||||
|
"prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh",
|
||||||
|
"prefs_users_dialog_username_label": "Nome utente, ad es. phil",
|
||||||
|
"prefs_users_dialog_password_label": "Password",
|
||||||
|
"prefs_users_dialog_button_cancel": "Annulla",
|
||||||
|
"prefs_users_dialog_button_add": "Aggiungere",
|
||||||
|
"prefs_users_dialog_button_save": "Salva",
|
||||||
|
"prefs_appearance_title": "Aspetto",
|
||||||
|
"prefs_appearance_language_title": "Lingua",
|
||||||
|
"priority_min": "min",
|
||||||
|
"priority_low": "basso",
|
||||||
|
"priority_default": "predefinito",
|
||||||
|
"priority_high": "alto",
|
||||||
|
"priority_max": "max",
|
||||||
|
"error_boundary_title": "Oh no, ntfy è andato in crash",
|
||||||
|
"error_boundary_description": "Questo ovviamente non dovrebbe accadere. Mi dispiace molto per questo.<br/>Se hai un minuto, per favore <githubLink>segnala su GitHub</githubLink>, o faccelo sapere tramite <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink> .",
|
||||||
|
"error_boundary_button_copy_stack_trace": "Copia traccia dello stack",
|
||||||
|
"error_boundary_stack_trace": "Traccia dello stack",
|
||||||
|
"error_boundary_gathering_info": "Raccogli più informazioni…",
|
||||||
|
"error_boundary_unsupported_indexeddb_title": "Navigazione privata non supportata",
|
||||||
|
"action_bar_show_menu": "Mostra menu",
|
||||||
|
"action_bar_send_test_notification": "Inviare una notifica di prova",
|
||||||
|
"alert_not_supported_description": "Le notifiche non sono supportate nel tuo browser.",
|
||||||
|
"nav_button_documentation": "Documentazione",
|
||||||
|
"notifications_actions_http_request_title": "Invia HTTP {{method}} a {{url}}",
|
||||||
|
"alert_grant_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop.",
|
||||||
|
"alert_not_supported_title": "Notifiche non supportate",
|
||||||
|
"notifications_attachment_file_app": "file app Android",
|
||||||
|
"notifications_no_subscriptions_description": "Fai clic sul link \"{{linktext}}\" per creare o iscriverti a un topic. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.",
|
||||||
|
"notifications_attachment_file_audio": "file audio",
|
||||||
|
"notifications_none_for_any_description": "Per inviare notifiche a un topic, è sufficiente PUT o POST all'URL del topic. Ecco un esempio utilizzando uno dei tuoi topic.",
|
||||||
|
"notifications_click_copy_url_title": "Copia l'URL del collegamento negli appunti",
|
||||||
|
"prefs_notifications_sound_description_none": "Le notifiche non emettono alcun suono quando arrivano",
|
||||||
|
"publish_dialog_delay_label": "Ritardo",
|
||||||
|
"publish_dialog_tags_placeholder": "Elenco di tag separato da virgole, ad es. avviso, backup-srv1",
|
||||||
|
"publish_dialog_click_placeholder": "URL che viene aperto quando si fa clic sulla notifica",
|
||||||
|
"publish_dialog_attach_placeholder": "Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk",
|
||||||
|
"publish_dialog_chip_topic_label": "Cambia topic",
|
||||||
|
"publish_dialog_details_examples_description": "Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla <docsLink>documentazione</docsLink>.",
|
||||||
|
"publish_dialog_attached_file_filename_placeholder": "Nome file allegato",
|
||||||
|
"emoji_picker_search_placeholder": "Cerca emoji",
|
||||||
|
"subscribe_dialog_subscribe_description": "Gli argomenti potrebbero non essere protetti da password, quindi scegli un nome che non sia facile da indovinare. Una volta iscritto, puoi inviare le notifiche tramite PUT/POST.",
|
||||||
|
"subscribe_dialog_subscribe_use_another_label": "Usa un altro server",
|
||||||
|
"subscribe_dialog_login_password_label": "Password",
|
||||||
|
"subscribe_dialog_subscribe_button_subscribe": "Iscriviti",
|
||||||
|
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
||||||
|
"prefs_notifications_min_priority_title": "Priorità minima",
|
||||||
|
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
||||||
|
"subscribe_dialog_login_button_back": "Indietro",
|
||||||
|
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
||||||
|
"prefs_notifications_title": "Notifiche",
|
||||||
|
"prefs_notifications_delete_after_title": "Elimina le notifiche",
|
||||||
|
"prefs_notifications_min_priority_default_and_higher": "Priorità predefinita e superiore",
|
||||||
|
"prefs_notifications_min_priority_description_x_or_higher": "Mostra le notifiche se la priorità è {{number}} ({{name}}) o superiore",
|
||||||
|
"prefs_notifications_delete_after_one_week": "Dopo una settimana",
|
||||||
|
"prefs_notifications_delete_after_one_month": "Dopo un mese",
|
||||||
|
"prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore",
|
||||||
|
"error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>."
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,5 +187,6 @@
|
|||||||
"prefs_users_table": "Tabela de usuários",
|
"prefs_users_table": "Tabela de usuários",
|
||||||
"prefs_users_edit_button": "Editar usuário",
|
"prefs_users_edit_button": "Editar usuário",
|
||||||
"prefs_users_delete_button": "Excluir usuário",
|
"prefs_users_delete_button": "Excluir usuário",
|
||||||
"error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada"
|
"error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada",
|
||||||
|
"error_boundary_unsupported_indexeddb_description": "O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.<br/><br/>Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso <githubLink>nesta edição do GitHub</githubLink>, ou falar conosco em <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>."
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
||||||
|
|
||||||
@@ -449,14 +449,17 @@ const Language = () => {
|
|||||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
|
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
|
||||||
<MenuItem value="en">English</MenuItem>
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
||||||
<MenuItem value="bg">Български</MenuItem>
|
<MenuItem value="bg">Български</MenuItem>
|
||||||
<MenuItem value="cs">Čeština</MenuItem>
|
<MenuItem value="cs">Čeština</MenuItem>
|
||||||
|
<MenuItem value="zh_Hans">中文</MenuItem>
|
||||||
<MenuItem value="de">Deutsch</MenuItem>
|
<MenuItem value="de">Deutsch</MenuItem>
|
||||||
<MenuItem value="es">Español</MenuItem>
|
<MenuItem value="es">Español</MenuItem>
|
||||||
<MenuItem value="fr">Français</MenuItem>
|
<MenuItem value="fr">Français</MenuItem>
|
||||||
|
<MenuItem value="it">Italiano</MenuItem>
|
||||||
<MenuItem value="hu">Magyar</MenuItem>
|
<MenuItem value="hu">Magyar</MenuItem>
|
||||||
<MenuItem value="id">Bahasa Indonesia</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user