diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6507eadc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/web" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + all: + patterns: + - "*" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 72b9e360..333ec3a0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,19 +1,22 @@ name: build -on: [ push, pull_request ] +on: + push: + branches: [ main ] + pull_request: jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.24.x' + go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Install dependencies diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 6991dea6..28af9f29 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout ntfy code - uses: actions/checkout@v3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Checkout docs pages code - uses: actions/checkout@v3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: binwiederhier/ntfy-docs.github.io path: build/ntfy-docs.github.io diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 70a70552..6eee546b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,21 +6,38 @@ on: jobs: release: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: ntfy + POSTGRES_PASSWORD: ntfy + POSTGRES_DB: ntfy_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ntfy" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" + NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.24.x' + go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Docker login - uses: docker/login-action@v2 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ github.repository_owner }} password: ${{ secrets.DOCKER_HUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cfd9d754..c89d32bf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,19 +1,39 @@ name: test -on: [ push, pull_request ] +on: + push: + branches: [ main ] + pull_request: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: ntfy + POSTGRES_PASSWORD: ntfy + POSTGRES_DB: ntfy_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ntfy" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" + NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.24.x' + go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Install dependencies @@ -23,8 +43,6 @@ jobs: - name: Build web app (required for tests) run: make web - name: Run tests, formatting, vetting and linting - run: make check + run: make checkv - name: Run coverage run: make coverage - - name: Upload coverage to codecov.io - run: make coverage-upload diff --git a/.gitignore b/.gitignore index cf10bc33..6d5deb67 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ build/ server/docs/ server/site/ tools/fbsend/fbsend +tools/pgimport/pgimport +tools/loadtest/loadtest +tools/s3cli/s3cli playground/ secrets/ *.iml diff --git a/.goreleaser.yml b/.goreleaser.yml index f0cf08f6..3c4e9c76 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -48,13 +48,15 @@ builds: - id: ntfy_windows_amd64 binary: ntfy env: - - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [ noserver ] # don't include server files + - CGO_ENABLED=1 # required for go-sqlite3 + - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64 + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" goos: [ windows ] - goarch: [ amd64 ] - - id: ntfy_darwin_all + goarch: [amd64 ] + - + id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 @@ -201,4 +203,4 @@ docker_manifests: - *amd64_image - *arm64v8_image - *armv7_image - - *armv6_image \ No newline at end of file + - *armv6_image diff --git a/Dockerfile-build b/Dockerfile-build index 78f2d5d9..0a7f0623 100644 --- a/Dockerfile-build +++ b/Dockerfile-build @@ -1,8 +1,8 @@ -FROM golang:1.24-bullseye as builder +FROM golang:1.25-bookworm AS builder ARG VERSION=dev ARG COMMIT=unknown -ARG NODE_MAJOR=18 +ARG NODE_MAJOR=24 RUN apt-get update && apt-get install -y \ build-essential ca-certificates curl gnupg \ @@ -21,14 +21,14 @@ ADD Makefile . # docs ADD ./requirements.txt . -RUN make docs-deps +RUN --mount=type=cache,target=/root/.cache/pip make docs-deps ADD ./mkdocs.yml . ADD ./docs ./docs RUN make docs-build # web ADD ./web/package.json ./web/package-lock.json ./web/ -RUN make web-deps +RUN --mount=type=cache,target=/root/.npm make web-deps ADD ./web ./web RUN make web-build @@ -40,7 +40,15 @@ ADD ./log ./log ADD ./server ./server ADD ./user ./user ADD ./util ./util -RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server +ADD ./payments ./payments +ADD ./db ./db +ADD ./message ./message +ADD ./model ./model +ADD ./webpush ./webpush +ADD ./attachment ./attachment +ADD ./mail ./mail +ADD ./s3 ./s3 +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server FROM alpine diff --git a/Makefile b/Makefile index df131c7a..d7e90f83 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ MAKEFLAGS := --jobs=1 +NPM := npm PYTHON := python3 PIP := pip3 VERSION := $(shell git describe --tag) COMMIT := $(shell git rev-parse --short HEAD) -.PHONY: +# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite +# forces that target's recipe to run every time (the classic "FORCE target" idiom). +FORCE: help: @echo "Typical commands (more see below):" @@ -31,6 +34,7 @@ help: @echo "Build server & client (without GoReleaser):" @echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)" @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" + @echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo @echo "Build dev Docker:" @@ -41,6 +45,7 @@ help: @echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-build - Actually build the web app" @echo " make web-lint - Run eslint on the web app" + @echo " make web-test - Run vitest unit tests for the web app" @echo " make web-fmt - Run prettier on the web app" @echo " make web-fmt-check - Run prettier on the web app, but don't change anything" @echo @@ -50,7 +55,9 @@ help: @echo " make docs-build - Actually build the documentation" @echo @echo "Test/check:" - @echo " make test - Run tests" + @echo " make test - Run all tests (Go + web)" + @echo " make cli-test - Run Go tests only" + @echo " make web-test - Run web app tests only" @echo " make race - Run tests with -race flag" @echo " make coverage - Run tests and show coverage" @echo " make coverage-html - Run tests and show coverage (as HTML)" @@ -80,7 +87,7 @@ help: # Building everything -clean: .PHONY +clean: FORCE rm -rf dist build server/docs server/site build: web docs cli @@ -106,6 +113,7 @@ build-deps-ubuntu: curl \ gcc-aarch64-linux-gnu \ gcc-arm-linux-gnueabi \ + gcc-mingw-w64-x86-64 \ python3 \ python3-venv \ jq @@ -116,7 +124,7 @@ build-deps-ubuntu: docs: docs-deps docs-build -docs-venv: .PHONY +docs-venv: FORCE $(PYTHON) -m venv ./venv docs-build: docs-venv @@ -125,7 +133,7 @@ docs-build: docs-venv docs-deps: docs-venv (. venv/bin/activate && $(PIP) install -r requirements.txt) -docs-deps-update: .PHONY +docs-deps-update: FORCE (. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade) @@ -135,7 +143,7 @@ web: web-deps web-build web-build: cd web \ - && npm run build \ + && $(NPM) run build \ && mv build/index.html build/app.html \ && rm -rf ../server/site \ && mv build ../server/site \ @@ -143,20 +151,25 @@ web-build: ../server/site/config.js web-deps: - cd web && npm install + cd web && $(NPM) ci + # Use "npm ci" so that we don't change the package lock file # If this fails for .svg files, optimize them with svgo web-deps-update: - cd web && npm update + cd web && $(NPM) update --before="$(shell date -d '7 days ago' +%Y-%m-%d)" + cd web && $(NPM) install web-fmt: - cd web && npm run format + cd web && $(NPM) run format web-fmt-check: - cd web && npm run format:check + cd web && $(NPM) run format:check web-lint: - cd web && npm run lint + cd web && $(NPM) run lint + +web-test: + cd web && $(NPM) run test # Main server/client build @@ -201,6 +214,16 @@ cli-darwin-server: cli-deps-static-sites -ldflags \ "-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" +cli-windows-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) for Windows. + # Use this for Windows development, if you really don't want to install GoReleaser ... + mkdir -p dist/ntfy_windows_server server/docs + CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \ + -o dist/ntfy_windows_server/ntfy.exe \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + cli-client: cli-deps-static-sites # This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows. # Use this for development, if you really don't want to install GoReleaser ... @@ -213,7 +236,7 @@ cli-client: cli-deps-static-sites cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc -cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 +cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows cli-deps-static-sites: mkdir -p server/docs server/site @@ -228,8 +251,12 @@ cli-deps-gcc-armv6-armv7: cli-deps-gcc-arm64: which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; } +cli-deps-gcc-windows: + which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; } + cli-deps-update: go get -u + go mod tidy go install honnef.co/go/tools/cmd/staticcheck@latest go install golang.org/x/lint/golint@latest go install github.com/goreleaser/goreleaser/v2@latest @@ -248,23 +275,29 @@ cli-build-results: check: test web-fmt-check fmt-check vet web-lint lint staticcheck -test: .PHONY - go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') +checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck -testv: .PHONY - go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') +test: cli-test web-test -race: .PHONY - go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') +testv: cli-testv web-test + +cli-test: FORCE + go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') + +cli-testv: FORCE + go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') + +race: FORCE + go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') coverage: mkdir -p build/coverage - go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') go tool cover -func build/coverage/coverage.txt coverage-html: mkdir -p build/coverage - go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') go tool cover -html build/coverage/coverage.txt coverage-upload: @@ -286,7 +319,7 @@ lint: which golint || go install golang.org/x/lint/golint@latest go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status -staticcheck: .PHONY +staticcheck: FORCE rm -rf build/staticcheck which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest mkdir -p build/staticcheck diff --git a/README.md b/README.md index 2d888468..caf27bb1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). +

+ + + +

+

diff --git a/SECURITY.md b/SECURITY.md index 45573756..a96cc823 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta ## Reporting a Vulnerability -Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w), -or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`). +Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). + +You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) +(my username is `binwiederhier`). diff --git a/attachment/backend.go b/attachment/backend.go new file mode 100644 index 00000000..921ceb3e --- /dev/null +++ b/attachment/backend.go @@ -0,0 +1,23 @@ +package attachment + +import ( + "io" + "time" +) + +// backendObject represents an object stored in a backend. +type object struct { + ID string + Size int64 + LastModified time.Time +} + +// backend is a minimal I/O interface for storing and retrieving attachment files. +// It has no knowledge of size tracking, limiting, or ID validation. +type backend interface { + Put(id string, reader io.Reader, untrustedLength int64) error + Get(id string) (io.ReadCloser, int64, error) + List() ([]object, error) + Delete(ids ...string) error + DeleteIncomplete(cutoff time.Time) error +} diff --git a/attachment/backend_file.go b/attachment/backend_file.go new file mode 100644 index 00000000..e86ff1ec --- /dev/null +++ b/attachment/backend_file.go @@ -0,0 +1,94 @@ +package attachment + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +type fileBackend struct { + dir string +} + +var _ backend = (*fileBackend)(nil) + +func newFileBackend(dir string) (*fileBackend, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + return &fileBackend{dir: dir}, nil +} + +func (b *fileBackend) Put(id string, reader io.Reader, untrustedLength int64) error { + if untrustedLength > 0 { + reader = io.LimitReader(reader, untrustedLength) + } + file := filepath.Join(b.dir, id) + f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + n, err := io.Copy(f, reader) + if err != nil { + os.Remove(file) + return err + } else if untrustedLength > 0 && n != untrustedLength { + os.Remove(file) + return fmt.Errorf("content length mismatch: claimed %d, got %d", untrustedLength, n) + } + if err := f.Close(); err != nil { + os.Remove(file) + return err + } + return nil +} + +func (b *fileBackend) List() ([]object, error) { + entries, err := os.ReadDir(b.dir) + if err != nil { + return nil, err + } + objects := make([]object, 0, len(entries)) + for _, e := range entries { + info, err := e.Info() + if err != nil { + return nil, err + } + objects = append(objects, object{ + ID: e.Name(), + Size: info.Size(), + LastModified: info.ModTime(), + }) + } + return objects, nil +} + +func (b *fileBackend) Get(id string) (io.ReadCloser, int64, error) { + file := filepath.Join(b.dir, id) + stat, err := os.Stat(file) + if err != nil { + return nil, 0, err + } + f, err := os.Open(file) + if err != nil { + return nil, 0, err + } + return f, stat.Size(), nil +} + +func (b *fileBackend) Delete(ids ...string) error { + for _, id := range ids { + file := filepath.Join(b.dir, id) + if err := os.Remove(file); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func (b *fileBackend) DeleteIncomplete(_ time.Time) error { + return nil +} diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go new file mode 100644 index 00000000..9a2d4bef --- /dev/null +++ b/attachment/backend_s3.go @@ -0,0 +1,51 @@ +package attachment + +import ( + "context" + "io" + "time" + + "heckel.io/ntfy/v2/s3" +) + +type s3Backend struct { + client *s3.Client +} + +var _ backend = (*s3Backend)(nil) + +func newS3Backend(client *s3.Client) *s3Backend { + return &s3Backend{client: client} +} + +func (b *s3Backend) Put(id string, reader io.Reader, untrustedLength int64) error { + return b.client.PutObject(context.Background(), id, reader, untrustedLength) +} + +func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) { + return b.client.GetObject(context.Background(), id) +} + +func (b *s3Backend) List() ([]object, error) { + objects, err := b.client.ListObjectsV2(context.Background()) + if err != nil { + return nil, err + } + result := make([]object, 0, len(objects)) + for _, obj := range objects { + result = append(result, object{ + ID: obj.Key, + Size: obj.Size, + LastModified: obj.LastModified, + }) + } + return result, nil +} + +func (b *s3Backend) Delete(ids ...string) error { + return b.client.DeleteObjects(context.Background(), ids) +} + +func (b *s3Backend) DeleteIncomplete(cutoff time.Time) error { + return b.client.AbortIncompleteUploads(context.Background(), cutoff) +} diff --git a/attachment/store.go b/attachment/store.go new file mode 100644 index 00000000..0b12877d --- /dev/null +++ b/attachment/store.go @@ -0,0 +1,246 @@ +package attachment + +import ( + "errors" + "fmt" + "io" + "sync" + "time" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/s3" + "heckel.io/ntfy/v2/util" +) + +const ( + tagStore = "attachment_store" + syncInterval = 15 * time.Minute // How often to run the background sync loop +) + +var errInvalidFileID = errors.New("invalid file ID") + +// Store manages attachment storage with shared logic for size tracking, limiting, +// ID validation, and background sync to reconcile storage with the database. +type Store struct { + backend backend + limit int64 // Defined limit of the store in bytes + size int64 // Current size of the store in bytes + sizes map[string]int64 // File ID -> size, for subtracting on Remove + attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments + orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this + closeChan chan struct{} + doneChan chan struct{} + mu sync.RWMutex // Protects size and sizes +} + +// NewFileStore creates a new file-system backed attachment cache +func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { + b, err := newFileBackend(dir) + if err != nil { + return nil, err + } + return newStore(b, totalSizeLimit, orphanGracePeriod, attachmentsWithSizes) +} + +// NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format: +// +// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true] +func NewS3Store(s3URL string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { + config, err := s3.ParseURL(s3URL) + if err != nil { + return nil, err + } + return newStore(newS3Backend(s3.New(config)), totalSizeLimit, orphanGracePeriod, attachmentsWithSizes) +} + +func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { + c := &Store{ + backend: backend, + limit: totalSizeLimit, + sizes: make(map[string]int64), + attachmentsWithSizes: attachmentsWithSizes, + orphanGracePeriod: orphanGracePeriod, + closeChan: make(chan struct{}), + doneChan: make(chan struct{}), + } + // Hydrate sizes from the database immediately so that Size()/Remaining()/Remove() + // are accurate from the start, without waiting for the first sync() call. + if attachmentsWithSizes != nil { + attachments, err := attachmentsWithSizes() + if err != nil { + return nil, fmt.Errorf("attachment store: failed to load existing attachments: %w", err) + } + for id, size := range attachments { + c.sizes[id] = size + c.size += size + } + go c.syncLoop() + } else { + close(c.doneChan) + } + return c, nil +} + +// Write stores an attachment file. The id is validated, and the write is subject to +// the total size limit and any additional limiters. The untrustedLength is a hint +// from the client's Content-Length header; backends may use it to optimize uploads (e.g. +// streaming directly to S3 without buffering). +func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limiters ...util.Limiter) (int64, error) { + if !model.ValidMessageID(id) { + return 0, errInvalidFileID + } + log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment") + limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) + countingReader := util.NewCountingReader(reader) + limitReader := util.NewLimitReader(countingReader, limiters...) + if err := c.backend.Put(id, limitReader, untrustedLength); err != nil { + c.backend.Delete(id) //nolint:errcheck + return 0, err + } + size := countingReader.Total() + c.mu.Lock() + c.size += size + c.sizes[id] = size + c.mu.Unlock() + return size, nil +} + +// Read retrieves an attachment file by ID +func (c *Store) Read(id string) (io.ReadCloser, int64, error) { + if !model.ValidMessageID(id) { + return nil, 0, errInvalidFileID + } + return c.backend.Get(id) +} + +// Remove deletes attachment files by ID and subtracts their known sizes from +// the total. Sizes for objects not tracked (e.g. written before this process +// started and before the first sync) are corrected by the next sync() call. +func (c *Store) Remove(ids ...string) error { + for _, id := range ids { + if !model.ValidMessageID(id) { + return errInvalidFileID + } + } + // Remove from backend + for _, id := range ids { + log.Tag(tagStore).Field("message_id", id).Debug("Removing attachment") + } + if err := c.backend.Delete(ids...); err != nil { + return err + } + // Update total cache size + c.mu.Lock() + for _, id := range ids { + if size, ok := c.sizes[id]; ok { + c.size -= size + delete(c.sizes, id) + } + } + if c.size < 0 { + c.size = 0 + } + c.mu.Unlock() + return nil +} + +// Sync triggers an immediate reconciliation of storage with the database. +func (c *Store) Sync() error { + return c.sync() +} + +// sync reconciles the backend storage with the database. It lists all objects, +// deletes orphans (not in the valid ID set and older than the grace period), and +// recomputes the total size from the existing attachments in the database. +func (c *Store) sync() error { + if c.attachmentsWithSizes == nil { + return nil + } + attachmentsWithSizes, err := c.attachmentsWithSizes() + if err != nil { + return fmt.Errorf("attachment sync: failed to get existing attachments: %w", err) + } + remoteObjects, err := c.backend.List() + if err != nil { + return fmt.Errorf("attachment sync: failed to list objects: %w", err) + } + // Calculate total cache size and collect orphaned attachments, excluding objects younger + // than the grace period to account for races, and skipping objects with invalid IDs. + cutoff := time.Now().Add(-c.orphanGracePeriod) + var orphanIDs []string + var count, totalSize int64 + sizes := make(map[string]int64, len(remoteObjects)) + for _, obj := range remoteObjects { + if !model.ValidMessageID(obj.ID) { + continue + } + if _, ok := attachmentsWithSizes[obj.ID]; !ok && obj.LastModified.Before(cutoff) { + orphanIDs = append(orphanIDs, obj.ID) + } else { + count++ + totalSize += attachmentsWithSizes[obj.ID] + sizes[obj.ID] = attachmentsWithSizes[obj.ID] + } + } + log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", count, util.FormatSizeHuman(totalSize)) + c.mu.Lock() + c.size = totalSize + c.sizes = sizes + c.mu.Unlock() + // Delete orphaned attachments + if len(orphanIDs) > 0 { + log.Tag(tagStore).Debug("Deleting %d orphaned attachment(s)", len(orphanIDs)) + if err := c.backend.Delete(orphanIDs...); err != nil { + return fmt.Errorf("attachment sync: failed to delete orphaned objects: %w", err) + } + } + // Clean up incomplete uploads (S3 only) + if err := c.backend.DeleteIncomplete(cutoff); err != nil { + log.Tag(tagStore).Err(err).Warn("Failed to abort incomplete uploads from attachment cache") + } + return nil +} + +// Size returns the current total size of all attachments +func (c *Store) Size() int64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.size +} + +// Remaining returns the remaining capacity for attachments +func (c *Store) Remaining() int64 { + c.mu.RLock() + defer c.mu.RUnlock() + remaining := c.limit - c.size + if remaining < 0 { + return 0 + } + return remaining +} + +// Close stops the background sync goroutine and waits for it to finish +func (c *Store) Close() { + close(c.closeChan) + <-c.doneChan +} + +func (c *Store) syncLoop() { + defer close(c.doneChan) + if err := c.sync(); err != nil { + log.Tag(tagStore).Err(err).Warn("Attachment sync failed") + } + ticker := time.NewTicker(syncInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := c.sync(); err != nil { + log.Tag(tagStore).Err(err).Warn("Attachment sync failed") + } + case <-c.closeChan: + return + } + } +} diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go new file mode 100644 index 00000000..0f7495b4 --- /dev/null +++ b/attachment/store_file_test.go @@ -0,0 +1,17 @@ +package attachment + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) { + t.Helper() + dir = t.TempDir() + cache, err := NewFileStore(dir, totalSizeLimit, time.Hour, nil) + require.Nil(t, err) + t.Cleanup(func() { cache.Close() }) + return dir, cache +} diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go new file mode 100644 index 00000000..22c1d6bf --- /dev/null +++ b/attachment/store_s3_test.go @@ -0,0 +1,120 @@ +package attachment + +import ( + "context" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/s3" +) + +func TestS3Store_WriteWithPrefix(t *testing.T) { + s3URL := os.Getenv("NTFY_TEST_S3_URL") + if s3URL == "" { + t.Skip("NTFY_TEST_S3_URL not set") + } + cfg, err := s3.ParseURL(s3URL) + require.Nil(t, err) + cfg.Prefix = "test-prefix" + client := s3.New(cfg) + deleteAllObjects(t, client) + backend := newS3Backend(client) + cache, err := newStore(backend, 10*1024, time.Hour, nil) + require.Nil(t, err) + t.Cleanup(func() { + deleteAllObjects(t, client) + cache.Close() + }) + + size, err := cache.Write("abcdefghijkl", strings.NewReader("test"), 0) + require.Nil(t, err) + require.Equal(t, int64(4), size) + + reader, _, err := cache.Read("abcdefghijkl") + require.Nil(t, err) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "test", string(data)) +} + +// --- Helpers --- + +func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOverrideBackend) { + t.Helper() + s3URL := os.Getenv("NTFY_TEST_S3_URL") + if s3URL == "" { + t.Skip("NTFY_TEST_S3_URL not set") + } + cfg, err := s3.ParseURL(s3URL) + require.Nil(t, err) + if cfg.Prefix != "" { + cfg.Prefix = cfg.Prefix + "/testpkg-attachment" + } else { + cfg.Prefix = "testpkg-attachment" + } + client := s3.New(cfg) + inner := newS3Backend(client) + wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)} + deleteAllObjects(t, client) + store, err := newStore(wrapper, totalSizeLimit, time.Hour, nil) + require.Nil(t, err) + t.Cleanup(func() { + deleteAllObjects(t, client) + store.Close() + }) + return store, wrapper +} + +func deleteAllObjects(t *testing.T, client *s3.Client) { + t.Helper() + for i := 0; i < 20; i++ { + objects, err := client.ListObjectsV2(context.Background()) + require.Nil(t, err) + if len(objects) == 0 { + return + } + keys := make([]string, len(objects)) + for j, obj := range objects { + keys[j] = obj.Key + } + require.Nil(t, client.DeleteObjects(context.Background(), keys)) + time.Sleep(200 * time.Millisecond) + } + t.Fatal("timed out waiting for bucket to be empty") +} + +// modTimeOverrideBackend wraps a backend and allows overriding LastModified times returned by List(). +// This is used in tests to simulate old objects on backends (like real S3) where +// LastModified cannot be set directly. +type modTimeOverrideBackend struct { + backend + mu sync.Mutex + modTimes map[string]time.Time // object ID -> override time +} + +func (b *modTimeOverrideBackend) List() ([]object, error) { + objects, err := b.backend.List() + if err != nil { + return nil, err + } + b.mu.Lock() + defer b.mu.Unlock() + for i, obj := range objects { + if t, ok := b.modTimes[obj.ID]; ok { + objects[i].LastModified = t + } + } + return objects, nil +} + +func (b *modTimeOverrideBackend) setModTime(id string, t time.Time) { + b.mu.Lock() + b.modTimes[id] = t + b.mu.Unlock() +} diff --git a/attachment/store_test.go b/attachment/store_test.go new file mode 100644 index 00000000..0cb32a3c --- /dev/null +++ b/attachment/store_test.go @@ -0,0 +1,352 @@ +package attachment + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/util" +) + +const testSizeLimit = 10 * 1024 + +func TestStore_WriteReadRemove(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Write + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 0) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, int64(11), s.Size()) + + // Read back + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, int64(11), readSize) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello world", string(data)) + + // Remove + require.Nil(t, s.Remove("abcdefghijkl")) + require.Equal(t, int64(0), s.Size()) + + // Read after remove should fail + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_WriteRemoveMultiple(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + for i := 0; i < 5; i++ { + _, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0) + require.Nil(t, err) + } + require.Equal(t, int64(500), s.Size()) + + require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk3")) + require.Equal(t, int64(300), s.Size()) + + // Removed files should not be readable + _, _, err := s.Read("abcdefghijk1") + require.Error(t, err) + _, _, err = s.Read("abcdefghijk3") + require.Error(t, err) + + // Remaining files should still be readable + for _, id := range []string{"abcdefghijk0", "abcdefghijk2", "abcdefghijk4"} { + reader, _, err := s.Read(id) + require.Nil(t, err) + reader.Close() + } + }) +} + +func TestStore_WriteTotalSizeLimit(t *testing.T) { + forEachBackend(t, 100, func(t *testing.T, s *Store, _ func(string)) { + // First write fits + _, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0) + require.Nil(t, err) + require.Equal(t, int64(80), s.Size()) + require.Equal(t, int64(20), s.Remaining()) + + // Second write exceeds total limit + _, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0) + require.ErrorIs(t, err, util.ErrLimitReached) + }) +} + +func TestStore_WriteAdditionalLimiter(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100)) + require.ErrorIs(t, err, util.ErrLimitReached) + + // File should not be readable (was cleaned up) + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_WriteWithLimiter(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999)) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, int64(11), s.Size()) + }) +} + +func TestStore_WriteOverwriteSameID(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Write 100 bytes + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 100)), 0) + require.Nil(t, err) + require.Equal(t, int64(100), s.Size()) + + // Overwrite with 50 bytes + _, err = s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 50)), 0) + require.Nil(t, err) + require.Equal(t, int64(150), s.Size()) // Store tracks both writes + + // Read back should return the latest content + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, int64(50), readSize) + reader.Close() + }) +} + +func TestStore_WriteAfterFailure(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Failed write: limiter rejects it + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100)) + require.ErrorIs(t, err, util.ErrLimitReached) + require.Equal(t, int64(0), s.Size()) + + // Subsequent write with a different ID should succeed + size, err := s.Write("abcdefghijk2", strings.NewReader("hello"), 0) + require.Nil(t, err) + require.Equal(t, int64(5), size) + require.Equal(t, int64(5), s.Size()) + + // The failed ID should not be readable + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + + // The successful ID should be readable + reader, _, err := s.Read("abcdefghijk2") + require.Nil(t, err) + reader.Close() + }) +} + +func TestStore_SyncRecomputesSize(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) { + // Write two files + _, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 100)), 0) + require.Nil(t, err) + _, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 200)), 0) + require.Nil(t, err) + require.Equal(t, int64(300), s.Size()) + + // Corrupt the in-memory size tracking + s.mu.Lock() + s.size = 999 + s.mu.Unlock() + require.Equal(t, int64(999), s.Size()) + + // Set attachmentsWithSizes to include both files so nothing gets deleted + s.attachmentsWithSizes = func() (map[string]int64, error) { + return map[string]int64{"abcdefghijk0": 100, "abcdefghijk1": 200}, nil + } + + // Sync should recompute size from the backend + require.Nil(t, s.sync()) + require.Equal(t, int64(300), s.Size()) + }) +} + +func TestStore_ReadNotFound(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + _, _, err := s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_InvalidID(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + _, err := s.Write("bad", strings.NewReader("x"), 0) + require.Equal(t, errInvalidFileID, err) + + _, _, err = s.Read("bad") + require.Equal(t, errInvalidFileID, err) + + err = s.Remove("bad") + require.Equal(t, errInvalidFileID, err) + }) +} + +func TestStore_WriteLargeObjects(t *testing.T) { + sizes := map[string]int64{ + "100B": 100, + "6MB": 6 * 1024 * 1024, + "12MB": 12 * 1024 * 1024, + } + for name, sz := range sizes { + t.Run(name, func(t *testing.T) { + forEachBackend(t, sz+1024, func(t *testing.T, s *Store, _ func(string)) { + data := make([]byte, sz) + for i := range data { + data[i] = byte(i % 251) + } + + size, err := s.Write("abcdefghijkl", bytes.NewReader(data), 0) + require.Nil(t, err) + require.Equal(t, sz, size) + require.Equal(t, sz, s.Size()) + + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, sz, readSize) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, data, got) + }) + }) + } +} + +func TestStore_WriteUntrustedLengthExact(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 11) + require.Nil(t, err) + require.Equal(t, int64(11), size) + + reader, _, err := s.Read("abcdefghijkl") + require.Nil(t, err) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello world", string(data)) + }) +} + +func TestStore_WriteUntrustedLengthBodyLonger(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 5) + require.Nil(t, err) + require.Equal(t, int64(5), size) + + reader, _, err := s.Read("abcdefghijkl") + require.Nil(t, err) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello", string(data)) + }) +} + +func TestStore_WriteUntrustedLengthBodyShorter(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Body has 5 bytes, but we claim 100 — should fail + _, err := s.Write("abcdefghijkl", strings.NewReader("hello"), 100) + require.Error(t, err) + + // File should not be readable (was cleaned up) + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_Sync(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) { + // Write some files + _, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0) + require.Nil(t, err) + _, err = s.Write("abcdefghijk1", strings.NewReader("file1"), 0) + require.Nil(t, err) + _, err = s.Write("abcdefghijk2", strings.NewReader("file2"), 0) + require.Nil(t, err) + + require.Equal(t, int64(15), s.Size()) + + // Set the ID provider to only know about file 0 and 2 + s.attachmentsWithSizes = func() (map[string]int64, error) { + return map[string]int64{"abcdefghijk0": 5, "abcdefghijk2": 5}, nil + } + + // Make file 1 old enough to be cleaned up + makeOld("abcdefghijk1") + + // Run sync + require.Nil(t, s.sync()) + + // File 1 should be deleted (orphan, old enough) + _, _, err = s.Read("abcdefghijk1") + require.Error(t, err) + + // Files 0 and 2 should still be readable + r, _, err := s.Read("abcdefghijk0") + require.Nil(t, err) + r.Close() + r, _, err = s.Read("abcdefghijk2") + require.Nil(t, err) + r.Close() + + // Size should be updated + require.Equal(t, int64(10), s.Size()) + }) +} + +func TestStore_Sync_SkipsRecentFiles(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Write a file + _, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0) + require.Nil(t, err) + + // Set the ID provider to return empty (no valid IDs) + s.attachmentsWithSizes = func() (map[string]int64, error) { + return map[string]int64{}, nil + } + + // File was just created, so it should NOT be deleted (< 1 hour old) + require.Nil(t, s.sync()) + + // File should still exist + reader, _, err := s.Read("abcdefghijk0") + require.Nil(t, err) + reader.Close() + }) +} + +// forEachBackend runs f against both the file and S3 backends. It also provides a makeOld +// callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour). +// For the file backend, this uses os.Chtimes; for the S3 backend, it overrides the object's +// LastModified time via a modTimeOverrideBackend wrapper. Objects start with recent timestamps +// by default. The S3 subtest is skipped if NTFY_TEST_S3_URL is not set. +func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) { + t.Run("file", func(t *testing.T) { + dir, s := newTestFileStore(t, totalSizeLimit) + makeOld := func(id string) { + oldTime := time.Unix(1, 0) + os.Chtimes(filepath.Join(dir, id), oldTime, oldTime) + } + f(t, s, makeOld) + }) + t.Run("s3", func(t *testing.T) { + s, wrapper := newTestRealS3Store(t, totalSizeLimit) + makeOld := func(id string) { + wrapper.setModTime(id, time.Unix(1, 0)) + } + f(t, s, makeOld) + }) +} diff --git a/client/config.go b/client/config.go index 870c835b..444460d6 100644 --- a/client/config.go +++ b/client/config.go @@ -11,6 +11,9 @@ const ( DefaultBaseURL = "https://ntfy.sh" ) +// DefaultConfigFile is the default path to the client config file (set in config_*.go) +var DefaultConfigFile string + // Config is the config struct for a Client type Config struct { DefaultHost string `yaml:"default-host"` diff --git a/client/config_darwin.go b/client/config_darwin.go new file mode 100644 index 00000000..c2488849 --- /dev/null +++ b/client/config_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package client + +import ( + "os" + "os/user" + "path/filepath" +) + +func init() { + u, err := user.Current() + if err == nil && u.Uid == "0" { + DefaultConfigFile = "/etc/ntfy/client.yml" + } else if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/config_unix.go b/client/config_unix.go new file mode 100644 index 00000000..273340e1 --- /dev/null +++ b/client/config_unix.go @@ -0,0 +1,18 @@ +//go:build linux || dragonfly || freebsd || netbsd || openbsd + +package client + +import ( + "os" + "os/user" + "path/filepath" +) + +func init() { + u, err := user.Current() + if err == nil && u.Uid == "0" { + DefaultConfigFile = "/etc/ntfy/client.yml" + } else if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/config_windows.go b/client/config_windows.go new file mode 100644 index 00000000..2ee55328 --- /dev/null +++ b/client/config_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package client + +import ( + "os" + "path/filepath" +) + +func init() { + if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/options.go b/client/options.go index f4711834..b99f1673 100644 --- a/client/options.go +++ b/client/options.go @@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) } +// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications +func WithSequenceID(sequenceID string) PublishOption { + return WithHeader("X-Sequence-ID", sequenceID) +} + // WithEmail instructs the server to also send the message to the given e-mail address func WithEmail(email string) PublishOption { return WithHeader("X-Email", email) diff --git a/cmd/app.go b/cmd/app.go index d88a9d58..d6df1add 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -3,11 +3,12 @@ package cmd import ( "fmt" + "os" + "regexp" + "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/log" - "os" - "regexp" ) const ( @@ -15,6 +16,12 @@ const ( categoryServer = "Server commands" ) +// Build metadata keys for app.Metadata +const ( + MetadataKeyCommit = "commit" + MetadataKeyDate = "date" +) + var commands = make([]*cli.Command, 0) var flagsDefault = []cli.Flag{ @@ -37,7 +44,7 @@ func New() *cli.App { Name: "ntfy", Usage: "Simple pub-sub notification service", UsageText: "ntfy [OPTION..]", - HideVersion: true, + HideVersion: false, UseShortOptionHandling: true, Reader: os.Stdin, Writer: os.Stdout, diff --git a/cmd/publish.go b/cmd/publish.go index f3139a63..c80c140b 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -34,6 +34,7 @@ var flagsPublish = append( &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, &cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"}, &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, + &cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"}, &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"}, @@ -70,6 +71,7 @@ Examples: ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment + ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates echo 'message' | ntfy publish mytopic # Send message from stdin ntfy pub -u phil:mypass secret Psst # Publish with username/password ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing @@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error { markdown := c.Bool("markdown") template := c.String("template") filename := c.String("filename") + sequenceID := c.String("sequence-id") file := c.String("file") email := c.String("email") user := c.String("user") @@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error { if filename != "" { options = append(options, client.WithFilename(filename)) } + if sequenceID != "" { + options = append(options, client.WithSequenceID(sequenceID)) + } if email != "" { options = append(options, client.WithEmail(email)) } diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 31d01cb5..1de9f5f9 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -2,9 +2,6 @@ package cmd import ( "fmt" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/test" - "heckel.io/ntfy/v2/util" "net/http" "net/http/httptest" "os" @@ -14,9 +11,14 @@ import ( "strings" "testing" "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/test" + "heckel.io/ntfy/v2/util" ) func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { + t.Skip("temporarily disabled") // FIXME testMessage := util.RandomString(10) app, _, _, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) diff --git a/cmd/publish_unix.go b/cmd/publish_unix.go index 3ce22ffc..d2b49a5e 100644 --- a/cmd/publish_unix.go +++ b/cmd/publish_unix.go @@ -1,5 +1,4 @@ //go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd -// +build darwin linux dragonfly freebsd netbsd openbsd package cmd diff --git a/cmd/serve.go b/cmd/serve.go index ab8d75ec..9712f94f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,10 +10,9 @@ import ( "net" "net/netip" "net/url" - "os" - "os/signal" + "runtime" "strings" - "syscall" + "text/template" "time" "github.com/urfave/cli/v2" @@ -40,6 +39,8 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}), @@ -51,7 +52,8 @@ var flagsServe = append( altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), @@ -70,6 +72,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), @@ -77,6 +80,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), @@ -90,6 +94,8 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), 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.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), @@ -143,6 +149,8 @@ func execServe(c *cli.Context) error { keyFile := c.String("key-file") certFile := c.String("cert-file") firebaseKeyFile := c.String("firebase-key-file") + databaseURL := c.String("database-url") + databaseReplicaURLs := c.StringSlice("database-replica-urls") webPushPrivateKey := c.String("web-push-private-key") webPushPublicKey := c.String("web-push-public-key") webPushFile := c.String("web-push-file") @@ -161,6 +169,7 @@ func execServe(c *cli.Context) error { authUsersRaw := c.StringSlice("auth-users") authAccessRaw := c.StringSlice("auth-access") authTokensRaw := c.StringSlice("auth-tokens") + authAccessCacheEnabled := c.Bool("auth-access-cache") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -180,6 +189,7 @@ func execServe(c *cli.Context) error { smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") smtpSenderFrom := c.String("smtp-sender-from") + smtpSenderVerify := c.Bool("smtp-sender-verify") smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") @@ -187,6 +197,7 @@ func execServe(c *cli.Context) error { twilioAuthToken := c.String("twilio-auth-token") twilioPhoneNumber := c.String("twilio-phone-number") twilioVerifyService := c.String("twilio-verify-service") + twilioCallFormat := c.String("twilio-call-format") messageSizeLimitStr := c.String("message-size-limit") messageDelayLimitStr := c.String("message-delay-limit") totalTopicLimit := c.Int("global-topic-limit") @@ -200,6 +211,8 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish") + visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst") + visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") behindProxy := c.Bool("behind-proxy") @@ -245,6 +258,10 @@ func execServe(c *cli.Context) error { if err != nil { return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr) } + visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr) + if err != nil { + return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr) + } webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr) if err != nil { return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr) @@ -279,12 +296,18 @@ func execServe(c *cli.Context) error { } // Check values - if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { + if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") && !strings.HasPrefix(databaseURL, "postgresql://") { + return errors.New("if database-url is set, it must start with postgres:// or postgresql://") + } else if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") { + return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set") + } else if len(databaseReplicaURLs) > 0 && databaseURL == "" { + return errors.New("database-replica-urls can only be used if database-url is also set") + } else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { return errors.New("if set, FCM key file must exist") } else if firebaseKeyFile != "" && !server.FirebaseAvailable { return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)") - } else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") { - return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") + } else if webPushPublicKey != "" && (webPushPrivateKey == "" || (webPushFile == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") { + return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") } else if keepaliveInterval < 5*time.Second { return errors.New("keepalive interval cannot be lower than five seconds") } else if managerInterval < 5*time.Second { @@ -299,6 +322,8 @@ func execServe(c *cli.Context) error { return errors.New("if listen-https is set, both key-file and cert-file must be set") } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") + } else if smtpSenderVerify && smtpSenderAddr == "" { + return errors.New("if smtp-sender-verify is set, smtp-sender-addr must also be set") } else if smtpServerListen != "" && smtpServerDomain == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { @@ -320,8 +345,8 @@ func execServe(c *cli.Context) error { return errors.New("if upstream-base-url is set, base-url must also be set") } else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL { return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications") - } else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") { - return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") + } else if authFile == "" && databaseURL == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") { + return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file or database-url is not set") } else if enableSignup && !enableLogin { return errors.New("cannot set enable-signup without also setting enable-login") } else if requireLogin && !enableLogin { @@ -330,8 +355,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") - } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { - return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || (authFile == "" && databaseURL == "")) { + return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file (or database-url) must also be set") } else if messageSizeLimit > server.DefaultMessageSizeLimit { log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients") if messageSizeLimit > 5*1024*1024 { @@ -347,6 +372,8 @@ func execServe(c *cli.Context) error { return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") + } else if runtime.GOOS == "windows" && listenUnix != "" { + return errors.New("listen-unix is not supported on Windows") } // Backwards compatibility @@ -409,6 +436,15 @@ func execServe(c *cli.Context) error { payments.Setup(stripeSecretKey) } + // Parse Twilio template + var twilioCallFormatTemplate *template.Template + if twilioCallFormat != "" { + twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat) + if err != nil { + return fmt.Errorf("failed to parse twilio-call-format template: %w", err) + } + } + // Add default forbidden topics disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) @@ -434,6 +470,7 @@ func execServe(c *cli.Context) error { conf.AuthUsers = authUsers conf.AuthAccess = authAccess conf.AuthTokens = authTokens + conf.AuthAccessCacheEnabled = authAccessCacheEnabled conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -449,6 +486,7 @@ func execServe(c *cli.Context) error { conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderFrom = smtpSenderFrom + conf.SMTPSenderVerify = smtpSenderVerify conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix @@ -456,6 +494,7 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService + conf.TwilioCallFormat = twilioCallFormatTemplate conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit @@ -469,6 +508,8 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish + conf.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst + conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.BehindProxy = behindProxy @@ -484,6 +525,8 @@ func execServe(c *cli.Context) error { conf.EnableMetrics = enableMetrics conf.MetricsListenHTTP = metricsListenHTTP conf.ProfileListenHTTP = profileListenHTTP + conf.DatabaseURL = databaseURL + conf.DatabaseReplicaURLs = databaseReplicaURLs conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile @@ -491,7 +534,17 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration - conf.Version = c.App.Version + conf.BuildVersion = c.App.Version + conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate) + conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit) + + // Check if we should run as a Windows service + if ranAsService, err := maybeRunAsService(conf); err != nil { + log.Fatal("%s", err.Error()) + } else if ranAsService { + log.Info("Exiting.") + return nil + } // Set up hot-reloading of config go sigHandlerConfigReload(config) @@ -507,22 +560,6 @@ func execServe(c *cli.Context) error { return 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 - } - if err := reloadLogLevel(inputSource); err != nil { - log.Warn("Reloading log level failed: %s", err.Error()) - } - } -} - func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { // Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32 prefix, err := netip.ParsePrefix(host) @@ -654,24 +691,17 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok return tokens, nil } -func reloadLogLevel(inputSource altsrc.InputSourceContext) error { - newLevelStr, err := inputSource.String("log-level") - if err != nil { - return fmt.Errorf("cannot load log level: %s", err.Error()) +func maybeFromMetadata(m map[string]any, key string) string { + if m == nil { + return "" } - overrides, err := inputSource.StringSlice("log-level-overrides") - if err != nil { - return fmt.Errorf("cannot load log level overrides (1): %s", err.Error()) + v, exists := m[key] + if !exists { + return "" } - log.ResetLevelOverrides() - if err := applyLogLevelOverrides(overrides); err != nil { - return fmt.Errorf("cannot load log level overrides (2): %s", err.Error()) + s, ok := v.(string) + if !ok { + return "" } - log.SetLevel(log.ToLevel(newLevelStr)) - if len(overrides) > 0 { - log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides)) - } else { - log.Info("Log level is %v", strings.ToUpper(newLevelStr)) - } - return nil + return s } diff --git a/cmd/serve_unix.go b/cmd/serve_unix.go new file mode 100644 index 00000000..cdb1bb63 --- /dev/null +++ b/cmd/serve_unix.go @@ -0,0 +1,55 @@ +//go:build (darwin || linux || dragonfly || freebsd || netbsd || openbsd) && !noserver + +package cmd + +import ( + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" +) + +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 + } + if err := reloadLogLevel(inputSource); err != nil { + log.Warn("Reloading log level failed: %s", err.Error()) + } + } +} + +func reloadLogLevel(inputSource altsrc.InputSourceContext) error { + newLevelStr, err := inputSource.String("log-level") + if err != nil { + return err + } + overrides, err := inputSource.StringSlice("log-level-overrides") + if err != nil { + return err + } + log.ResetLevelOverrides() + if err := applyLogLevelOverrides(overrides); err != nil { + return err + } + log.SetLevel(log.ToLevel(newLevelStr)) + if len(overrides) > 0 { + log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides)) + } else { + log.Info("Log level is %v", newLevelStr) + } + return nil +} + +func maybeRunAsService(conf *server.Config) (bool, error) { + return false, nil +} diff --git a/cmd/serve_windows.go b/cmd/serve_windows.go new file mode 100644 index 00000000..e917a079 --- /dev/null +++ b/cmd/serve_windows.go @@ -0,0 +1,100 @@ +//go:build windows && !noserver + +package cmd + +import ( + "fmt" + "sync" + + "golang.org/x/sys/windows/svc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" +) + +const serviceName = "ntfy" + +// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available. +// Windows users can restart the service to reload configuration. +func sigHandlerConfigReload(config string) { + log.Debug("Config hot-reload via SIGHUP is not supported on Windows") +} + +// runAsWindowsService runs the ntfy server as a Windows service +func runAsWindowsService(conf *server.Config) error { + return svc.Run(serviceName, &windowsService{conf: conf}) +} + +// windowsService implements the svc.Handler interface +type windowsService struct { + conf *server.Config + server *server.Server + mu sync.Mutex +} + +// Execute is the main entry point for the Windows service +func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + + // Create and start the server + var err error + s.mu.Lock() + s.server, err = server.New(s.conf) + s.mu.Unlock() + if err != nil { + log.Error("Failed to create server: %s", err.Error()) + return true, 1 + } + + // Start server in a goroutine + serverErrChan := make(chan error, 1) + go func() { + serverErrChan <- s.server.Run() + }() + + status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + log.Info("Windows service started") + + for { + select { + case err := <-serverErrChan: + if err != nil { + log.Error("Server error: %s", err.Error()) + return true, 1 + } + return false, 0 + case req := <-requests: + switch req.Cmd { + case svc.Interrogate: + status <- req.CurrentStatus + case svc.Stop, svc.Shutdown: + log.Info("Windows service stopping...") + status <- svc.Status{State: svc.StopPending} + s.mu.Lock() + if s.server != nil { + s.server.Stop() + } + s.mu.Unlock() + return false, 0 + default: + log.Warn("Unexpected service control request: %d", req.Cmd) + } + } + } +} + +// maybeRunAsService checks if the process is running as a Windows service, +// and if so, runs the server as a service. Returns true if it ran as a service. +func maybeRunAsService(conf *server.Config) (bool, error) { + isService, err := svc.IsWindowsService() + if err != nil { + return false, fmt.Errorf("failed to detect Windows service mode: %w", err) + } else if !isService { + return false, nil + } + log.Info("Running as Windows service") + if err := runAsWindowsService(conf); err != nil { + return true, fmt.Errorf("failed to run as Windows service: %w", err) + } + return true, nil +} diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 5ebf9627..84450927 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -3,28 +3,21 @@ package cmd import ( "errors" "fmt" + "os" + "os/exec" + "sort" + "strings" + "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" - "os" - "os/exec" - "os/user" - "path/filepath" - "sort" - "strings" ) func init() { commands = append(commands, cmdSubscribe) } -const ( - clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml" - clientUserConfigFileUnixRelative = "ntfy/client.yml" - clientUserConfigFileWindowsRelative = "ntfy\\client.yml" -) - var flagsSubscribe = append( append([]cli.Flag{}, flagsDefault...), &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, @@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) { if filename != "" { return client.LoadConfig(filename) } - configFile, err := defaultClientConfigFile() - if err != nil { - log.Warn("Could not determine default client config file: %s", err.Error()) - } else { - if s, _ := os.Stat(configFile); s != nil { - return client.LoadConfig(configFile) + if client.DefaultConfigFile != "" { + if s, _ := os.Stat(client.DefaultConfigFile); s != nil { + return client.LoadConfig(client.DefaultConfigFile) } - log.Debug("Config file %s not found", configFile) + log.Debug("Config file %s not found", client.DefaultConfigFile) } log.Debug("Loading default config") return client.NewConfig(), nil } -//lint:ignore U1000 Conditionally used in different builds -func defaultClientConfigFileUnix() (string, error) { - u, err := user.Current() - if err != nil { - return "", fmt.Errorf("could not determine current user: %w", err) - } - configFile := clientRootConfigFileUnixAbsolute - if u.Uid != "0" { - homeDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine user config dir: %w", err) - } - return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil - } - return configFile, nil -} - -//lint:ignore U1000 Conditionally used in different builds -func defaultClientConfigFileWindows() (string, error) { - homeDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine user config dir: %w", err) - } - return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil -} - func logMessagePrefix(m *client.Message) string { return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID) } diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go index 487f0641..00335540 100644 --- a/cmd/subscribe_darwin.go +++ b/cmd/subscribe_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package cmd const ( @@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.` var ( scriptLauncher = []string{"sh", "-c"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileUnix() -} diff --git a/cmd/subscribe_unix.go b/cmd/subscribe_unix.go index 3f5f526f..4c9c6039 100644 --- a/cmd/subscribe_unix.go +++ b/cmd/subscribe_unix.go @@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.` var ( scriptLauncher = []string{"sh", "-c"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileUnix() -} diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go index 22c07d81..ea5f09f0 100644 --- a/cmd/subscribe_windows.go +++ b/cmd/subscribe_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package cmd const ( @@ -9,7 +11,3 @@ const ( var ( scriptLauncher = []string{"cmd.exe", "/Q", "/C"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileWindows() -} diff --git a/cmd/user.go b/cmd/user.go index 6bf7030e..9e33e1da 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -6,13 +6,17 @@ import ( "crypto/subtle" "errors" "fmt" - "heckel.io/ntfy/v2/server" - "heckel.io/ntfy/v2/user" "os" "strings" + "time" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" + "heckel.io/ntfy/v2/mail" + "heckel.io/ntfy/v2/server" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -29,12 +33,18 @@ var flagsUser = append( &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}), + 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: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), ) var cmdUser = &cli.Command{ Name: "user", Usage: "Manage/show users", - UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", + UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...", Flags: flagsUser, Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Category: categoryServer, @@ -95,6 +105,30 @@ Example: You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. +`, + }, + { + Name: "reset-pass", + Aliases: []string{"rp"}, + Usage: "Generates a password reset link for a user", + UsageText: "ntfy user reset-pass [--send-email] USERNAME", + Action: execUserResetPass, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"}, + }, + Description: `Generate a password reset link for the given user and print it to stdout. + +The user completes the reset by opening the link in a browser and choosing a new password; +the admin never learns or chooses the new password. The link is single-use and expires after +one hour. This is an admin override of the self-service reset flow -- unlike self-service, it +does not require the user to have a verified primary email (the token is bound to the user). + +With --send-email, the link is additionally emailed to the user's primary email address (this +requires SMTP to be configured and the user to have a verified primary email). + +Example: + ntfy user reset-pass phil # Print a reset link for user phil + ntfy user reset-pass --send-email phil # Print and email the reset link `, }, { @@ -254,7 +288,6 @@ func execUserDel(c *cli.Context) error { func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH") - if !hashed { password = os.Getenv("NTFY_PASSWORD") } @@ -283,6 +316,61 @@ func execUserChangePass(c *cli.Context) error { return nil } +func execUserResetPass(c *cli.Context) error { + username := c.Args().Get(0) + sendEmail := c.Bool("send-email") + baseURL := strings.TrimSuffix(c.String("base-url"), "/") + if username == "" { + return errors.New("username expected, type 'ntfy user reset-pass --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } else if baseURL == "" { + return errors.New("base-url must be configured to generate a reset link") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if errors.Is(err, user.ErrUserNotFound) { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } else if u.Provisioned { + return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username) + } + // Resolve the primary email up front if we need to send -- fail before creating a token + var primaryEmail string + if sendEmail { + primaryEmail, err = manager.PrimaryEmail(u.ID) + if err != nil { + return err + } else if primaryEmail == "" { + return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username) + } + } + // The reset token is bound to the user, not an email -- so this works even with no SMTP + token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour) + if err != nil { + return err + } + link := baseURL + "/account/password/reset/" + token + fmt.Fprintln(c.App.Writer, link) + if sendEmail { + sender := mail.NewSender(&mail.Config{ + SMTPAddr: c.String("smtp-sender-addr"), + SMTPUser: c.String("smtp-sender-user"), + SMTPPass: c.String("smtp-sender-pass"), + From: c.String("smtp-sender-from"), + }) + if err := sender.SendPasswordReset(primaryEmail, link); err != nil { + return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err) + } + fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail) + } + return nil +} + func execUserChangeRole(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.Args().Get(1)) @@ -310,7 +398,7 @@ func execUserHash(c *cli.Context) error { if err != nil { return err } - hash, err := user.HashPassword(password) + hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } @@ -365,24 +453,31 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - if authFile == "" { - return nil, errors.New("option auth-file not set; auth is unconfigured for this server") - } else if !util.FileExists(authFile) { - return nil, errors.New("auth-file does not exist; please start the server at least once to create it") - } + databaseURL := c.String("database-url") authDefault, err := user.ParsePermission(authDefaultAccess) if err != nil { return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } authConfig := &user.Config{ - Filename: authFile, - StartupQueries: authStartupQueries, DefaultAccess: authDefault, ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + AccessCacheEnabled: false, // Do not cache for CLI commands } - return user.NewManager(authConfig) + if databaseURL != "" { + host, dbErr := pg.Open(databaseURL) + if dbErr != nil { + return nil, dbErr + } + return user.NewPostgresManager(db.New(host, nil), authConfig) + } else if authFile != "" { + if !util.FileExists(authFile) { + return nil, errors.New("auth-file does not exist; please start the server at least once to create it") + } + return user.NewSQLiteManager(authFile, authStartupQueries, authConfig) + } + return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server") } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/cmd/user_test.go b/cmd/user_test.go index ed6f5de4..bde0fa1f 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -1,14 +1,15 @@ package cmd import ( + "os" + "path/filepath" + "testing" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "heckel.io/ntfy/v2/user" - "os" - "path/filepath" - "testing" ) func TestCLI_User_Add(t *testing.T) { @@ -121,6 +122,69 @@ func TestCLI_User_Delete(t *testing.T) { require.Contains(t, err.Error(), "user phil does not exist") } +func TestCLI_User_ResetPass(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // Prints a working-looking reset link when base-url is set + app, _, stdout, _ := newTestApp() + require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil")) + require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") +} + +func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "reset-pass", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "base-url") +} + +func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // --send-email requires a primary email; phil has none + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "no primary email") +} + +func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Seed a provisioned user into the auth database via config provisioning + m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{ + ProvisionEnabled: true, + Users: []*user.User{ + {Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser}, + }, + }) + require.Nil(t, err) + require.Nil(t, m.Close()) + + app, _, _, _ := newTestApp() + err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser") + require.Error(t, err) + require.Contains(t, err.Error(), "provisioned") +} + func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { configFile := filepath.Join(t.TempDir(), "server-dummy.yml") require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml @@ -128,6 +192,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, conf.File = configFile conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthDefault = user.PermissionDenyAll + conf.AuthAccessCacheEnabled = false s, port = test.StartServerWithConfig(t, conf) return } diff --git a/db/db.go b/db/db.go new file mode 100644 index 00000000..6586e763 --- /dev/null +++ b/db/db.go @@ -0,0 +1,137 @@ +package db + +import ( + "context" + "database/sql" + "sync/atomic" + "time" + + "heckel.io/ntfy/v2/log" +) + +const ( + tag = "db" + replicaHealthCheckInitialDelay = 5 * time.Second + replicaHealthCheckInterval = 30 * time.Second + replicaHealthCheckTimeout = 10 * time.Second +) + +// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods +// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica +// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy. +type DB struct { + primary *Host + replicas []*Host + counter atomic.Uint64 + cancel context.CancelFunc +} + +// New creates a new DB that wraps the given primary and optional replica connections. +// If replicas is nil or empty, ReadOnly() simply returns the primary. +// Replicas start unhealthy and are checked immediately by a background goroutine. +func New(primary *Host, replicas []*Host) *DB { + ctx, cancel := context.WithCancel(context.Background()) + d := &DB{ + primary: primary, + replicas: replicas, + cancel: cancel, + } + if len(d.replicas) > 0 { + go d.healthCheckLoop(ctx) + } + return d +} + +// Query delegates to the primary database. +func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { + return d.primary.DB.Query(query, args...) +} + +// QueryRow delegates to the primary database. +func (d *DB) QueryRow(query string, args ...any) *sql.Row { + return d.primary.DB.QueryRow(query, args...) +} + +// Exec delegates to the primary database. +func (d *DB) Exec(query string, args ...any) (sql.Result, error) { + return d.primary.DB.Exec(query, args...) +} + +// Begin delegates to the primary database. +func (d *DB) Begin() (*sql.Tx, error) { + return d.primary.DB.Begin() +} + +// Ping delegates to the primary database. +func (d *DB) Ping() error { + return d.primary.DB.Ping() +} + +// Primary returns the underlying primary *sql.DB. This is only intended for +// one-time schema setup during store initialization, not for regular queries. +func (d *DB) Primary() *sql.DB { + return d.primary.DB +} + +// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy +// replicas. If all replicas are unhealthy or none are configured, the primary is returned. +func (d *DB) ReadOnly() *sql.DB { + if len(d.replicas) == 0 { + return d.primary.DB + } + n := len(d.replicas) + start := int(d.counter.Add(1) - 1) + for i := 0; i < n; i++ { + r := d.replicas[(start+i)%n] + if r.healthy.Load() { + return r.DB + } + } + return d.primary.DB +} + +// Close closes the primary database and all replicas, and stops the health-check goroutine. +func (d *DB) Close() error { + d.cancel() + for _, r := range d.replicas { + r.DB.Close() + } + return d.primary.DB.Close() +} + +// healthCheckLoop checks replicas immediately, then periodically on a ticker. +func (d *DB) healthCheckLoop(ctx context.Context) { + select { + case <-ctx.Done(): + return + case <-time.After(replicaHealthCheckInitialDelay): + d.checkReplicas(ctx) + } + for { + select { + case <-ctx.Done(): + return + case <-time.After(replicaHealthCheckInterval): + d.checkReplicas(ctx) + } + } +} + +// checkReplicas pings each replica with a timeout and updates its health status. +func (d *DB) checkReplicas(ctx context.Context) { + for _, r := range d.replicas { + wasHealthy := r.healthy.Load() + pingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout) + err := r.DB.PingContext(pingCtx) + cancel() + if err != nil { + r.healthy.Store(false) + log.Tag(tag).Error("Database replica %s is unhealthy: %s", r.Addr, err) + } else { + r.healthy.Store(true) + if !wasHealthy { + log.Tag(tag).Info("Database replica %s is healthy", r.Addr) + } + } + } +} diff --git a/db/pg/pg.go b/db/pg/pg.go new file mode 100644 index 00000000..3b034736 --- /dev/null +++ b/db/pg/pg.go @@ -0,0 +1,120 @@ +package pg + +import ( + "database/sql" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver + + "heckel.io/ntfy/v2/db" +) + +// Open opens a PostgreSQL connection pool for a primary database. It pings the database +// to verify connectivity before returning. +func Open(dsn string) (*db.Host, error) { + d, err := open(dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + if err := d.DB.Ping(); err != nil { + return nil, fmt.Errorf("database ping failed on %v: %w", d.Addr, err) + } + return d, nil +} + +// OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does +// not ping the database, since replicas are health-checked in the background by db.DB. +func OpenReplica(dsn string) (*db.Host, error) { + return open(dsn) +} + +// open opens a PostgreSQL database connection pool from a DSN string. It supports custom +// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, +// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from +// the DSN before passing it to the driver. +func open(dsn string) (*db.Host, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("invalid database URL: %w", err) + } + switch u.Scheme { + case "postgres", "postgresql": + // OK + default: + return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u)) + } + q := u.Query() + maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10) + if err != nil { + return nil, err + } + maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0) + if err != nil { + return nil, err + } + connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0) + if err != nil { + return nil, err + } + connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0) + if err != nil { + return nil, err + } + u.RawQuery = q.Encode() + d, err := sql.Open("pgx", u.String()) + if err != nil { + return nil, err + } + d.SetMaxOpenConns(maxOpenConns) + if maxIdleConns > 0 { + d.SetMaxIdleConns(maxIdleConns) + } + if connMaxLifetime > 0 { + d.SetConnMaxLifetime(connMaxLifetime) + } + if connMaxIdleTime > 0 { + d.SetConnMaxIdleTime(connMaxIdleTime) + } + return &db.Host{ + Addr: u.Host, + DB: d, + }, nil +} + +func extractIntParam(q url.Values, key string, defaultValue int) (int, error) { + s := q.Get(key) + if s == "" { + return defaultValue, nil + } + q.Del(key) + v, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) + } + return v, nil +} + +// censorPassword returns a string representation of the URL with the password replaced by "*****". +func censorPassword(u *url.URL) string { + if password, hasPassword := u.User.Password(); hasPassword { + return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1) + } + return u.String() +} + +func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) { + s := q.Get(key) + if s == "" { + return defaultValue, nil + } + q.Del(key) + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) + } + return d, nil +} diff --git a/db/pg/pg_test.go b/db/pg/pg_test.go new file mode 100644 index 00000000..cc66d7e9 --- /dev/null +++ b/db/pg/pg_test.go @@ -0,0 +1,53 @@ +package pg + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpen_InvalidScheme(t *testing.T) { + _, err := Open("postgresql+psycopg2://user:pass@localhost/db") + require.Error(t, err) + require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`) + require.Contains(t, err.Error(), "*****") + require.NotContains(t, err.Error(), "pass") +} + +func TestOpen_InvalidURL(t *testing.T) { + _, err := Open("not a valid url\x00") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid database URL") +} + +func TestCensorPassword(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "with password", + url: "postgres://user:secret@localhost/db", + expected: "postgres://user:*****@localhost/db", + }, + { + name: "without password", + url: "postgres://localhost/db", + expected: "postgres://localhost/db", + }, + { + name: "user only", + url: "postgres://user@localhost/db", + expected: "postgres://user@localhost/db", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.url) + require.NoError(t, err) + require.Equal(t, tt.expected, censorPassword(u)) + }) + } +} diff --git a/db/test/test.go b/db/test/test.go new file mode 100644 index 00000000..8d3f329b --- /dev/null +++ b/db/test/test.go @@ -0,0 +1,64 @@ +package dbtest + +import ( + "fmt" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" + "heckel.io/ntfy/v2/util" +) + +const testPoolMaxConns = "2" + +// CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it. +// It registers a cleanup function to drop the schema when the test finishes. +// If NTFY_TEST_DATABASE_URL is not set, the test is skipped. +func CreateTestPostgresSchema(t *testing.T) string { + t.Helper() + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set") + } + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("pool_max_conns", testPoolMaxConns) + u.RawQuery = q.Encode() + dsn = u.String() + setupHost, err := pg.Open(dsn) + require.Nil(t, err) + _, err = setupHost.DB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupHost.DB.Close()) + q.Set("search_path", schema) + u.RawQuery = q.Encode() + schemaDSN := u.String() + t.Cleanup(func() { + cleanHost, err := pg.Open(dsn) + if err == nil { + cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanHost.DB.Close() + } + }) + return schemaDSN +} + +// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it. +// It registers cleanup functions to close the DB and drop the schema when the test finishes. +// If NTFY_TEST_DATABASE_URL is not set, the test is skipped. +func CreateTestPostgres(t *testing.T) *db.DB { + t.Helper() + schemaDSN := CreateTestPostgresSchema(t) + testHost, err := pg.Open(schemaDSN) + require.Nil(t, err) + d := db.New(testHost, nil) + t.Cleanup(func() { + d.Close() + }) + return d +} diff --git a/db/types.go b/db/types.go new file mode 100644 index 00000000..137753a4 --- /dev/null +++ b/db/types.go @@ -0,0 +1,25 @@ +package db + +import ( + "database/sql" + "sync/atomic" +) + +// Beginner is an interface for types that can begin a database transaction. +// Both *sql.DB and *DB implement this. +type Beginner interface { + Begin() (*sql.Tx, error) +} + +// Querier is an interface for types that can execute SQL queries. +// *sql.DB, *sql.Tx, and *DB all implement this. +type Querier interface { + Query(query string, args ...any) (*sql.Rows, error) +} + +// Host pairs a *sql.DB with the host:port it was opened against. +type Host struct { + Addr string // "host:port" + DB *sql.DB + healthy atomic.Bool +} diff --git a/db/util.go b/db/util.go new file mode 100644 index 00000000..4621cb38 --- /dev/null +++ b/db/util.go @@ -0,0 +1,36 @@ +package db + +import "database/sql" + +// ExecTx executes a function within a database transaction. If the function returns an error, +// the transaction is rolled back. Otherwise, the transaction is committed. +func ExecTx(db Beginner, f func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := f(tx); err != nil { + return err + } + return tx.Commit() +} + +// QueryTx executes a function within a database transaction and returns the result. If the function +// returns an error, the transaction is rolled back. Otherwise, the transaction is committed. +func QueryTx[T any](db Beginner, f func(tx *sql.Tx) (T, error)) (T, error) { + tx, err := db.Begin() + if err != nil { + var zero T + return zero, err + } + defer tx.Rollback() + t, err := f(tx) + if err != nil { + return t, err + } + if err := tx.Commit(); err != nil { + return t, err + } + return t, nil +} diff --git a/docs/config.md b/docs/config.md index 74325dad..0f72557b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -53,6 +53,16 @@ Here are a few working sample configs using a `/etc/ntfy/server.yml` file: behind-proxy: true ``` +=== "server.yml (PostgreSQL, behind proxy)" + ``` yaml + base-url: "https://ntfy.example.com" + listen-http: ":2586" + database-url: "postgres://ntfy:mypassword@db.example.com:5432/ntfy?sslmode=require" + attachment-cache-dir: "/var/cache/ntfy/attachments" + behind-proxy: true + auth-default-access: "deny-all" + ``` + === "server.yml (ntfy.sh config)" ``` yaml # All the things: Behind a proxy, Firebase, cache, attachments, @@ -125,16 +135,357 @@ using Docker Compose (i.e. `docker-compose.yml`): command: serve ``` +## Config generator + +This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point. Please refer to the relevant sections in the doc for more details. + +

+ +
+ +
+ +
The config generator helps you create a custom config for your self-hosted ntfy instance. Click to open.
+
+ + + +## Database options +ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions. +You can choose between **SQLite** and **PostgreSQL** as the database backend. + +### SQLite +By default, ntfy uses SQLite with separate database files for each store. This is the simplest setup and requires +no external dependencies: + +* `cache-file`: Database file for the [message cache](#message-cache). +* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth. +* `web-push-file`: Database file for [web push](#web-push) subscriptions. + +### PostgreSQL (EXPERIMENTAL) +As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the +`database-url` option to a PostgreSQL connection string. + +When `database-url` is set, ntfy will use PostgreSQL for the [message cache](#message-cache), +[access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`, +`auth-file`, and `web-push-file` options **must not** be set in this case. + +Note that setting `database-url` implicitly enables authentication and access control (equivalent to setting +`auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all +topics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)). + +You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. + +To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas +using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages, checking access permissions, etc) +are distributed across the replicas using round-robin, while all writes and correctness-critical reads continue to go +to the primary. If a replica becomes unhealthy, ntfy automatically falls back to the primary until the replica recovers. +You can also set this via the environment variable `NTFY_DATABASE_REPLICA_URLS` (comma-separated) or the command line +flag `--database-replica-urls`. + +Examples: + +=== "Simple" + ```yaml + database-url: "postgres://user:pass@host:5432/ntfy" + ``` + +=== "With SSL and pool tuning" + ```yaml + database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m" + ``` + +=== "With CA certificate" + ```yaml + database-url: "postgres://user:pass@host:25060/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" + ``` + +=== "With read replicas" + ```yaml + database-url: "postgres://user:pass@primary:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" + database-replica-urls: + - "postgres://user:pass@replica1:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" + - "postgres://user:pass@replica2:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30" + ``` + +The database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) +as query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`. +See the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters. + +In addition, ntfy supports the following custom query parameters to tune the connection pool (these apply to both +the primary and replica URLs): + +| Parameter | Default | Description | +|---------------------------|---------|----------------------------------------------------------------------------------| +| `pool_max_conns` | 10 | Maximum number of open connections to the database | +| `pool_max_idle_conns` | - | Maximum number of idle connections in the pool | +| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | +| `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) | + + ## Message cache If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve notifications that they may have missed. By default, ntfy keeps messages **in-memory for 12 hours**, which means that **cached messages do not survive an application -restart**. You can override this behavior using the following config settings: +restart**. You can override this behavior by setting `cache-file` (SQLite) or `database-url` (PostgreSQL). -* `cache-file`: if set, ntfy will store messages in a SQLite based cache (default is empty, which means in-memory cache). - **This is required if you'd like messages to be retained across restarts**. * `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`). You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only @@ -146,30 +497,41 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable -this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). -Once these options are set and the directory is writable by the server user, you can upload attachments via PUT. +this feature, you have to configure an attachment storage backend and a base URL (`base-url`). Attachments can be stored +either on the [local filesystem](#filesystem-storage) or in an [S3-compatible object store](#s3-storage), both using the `attachment-cache-dir` option. +Once configured, you can upload attachments via PUT. -By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues -and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download -feature) to download the file. The following config options are relevant to attachments: +By default, attachments are stored **for only 3 hours**. The main reason for this is to avoid legal issues +and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download +feature) to download the file. You can increase this time by [purchasing ntfy Pro](https://ntfy.sh/app) via the web app. + +The following config options are relevant to attachments: * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs -* `attachment-cache-dir` is the cache directory for attached files -* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G) +* `attachment-cache-dir` is the cache directory for attached files, or an S3 URL for object storage +* `attachment-total-size-limit` is the size limit of the attachment storage (default: 5G) * `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) * `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) -Here's an example config using mostly the defaults (except for the cache directory, which is empty by default): +!!! warning + ntfy takes full control over the attachment directory or S3 bucket. Files that match the message ID format without + entries in the message table will be deleted. **Do not use a directory or S3 bucket that is also used for something else.** + +Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` +and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. + +### Filesystem storage +Here's an example config using the local filesystem for attachment storage: === "/etc/ntfy/server.yml (minimal)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "/var/cache/ntfy/attachments" ``` === "/etc/ntfy/server.yml (all options)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "/var/cache/ntfy/attachments" attachment-total-size-limit: "5G" attachment-file-size-limit: "15M" @@ -178,21 +540,87 @@ Here's an example config using mostly the defaults (except for the cache directo visitor-attachment-daily-bandwidth-limit: "500M" ``` -Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` -and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. +### S3 storage +As an alternative to the local filesystem, you can store attachments in an S3-compatible object store (e.g. [AWS S3](https://aws.amazon.com/s3/), +[DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. +To use an S3-compatible storage for attachments, set `attachment-cache-dir` to an S3 URL with the following format: + +``` +s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true] +``` + +Here are a few examples: + +=== "/etc/ntfy/server.yml (DigitalOcean Spaces)" + ``` yaml + base-url: "https://ntfy.example.com" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com&disable_http2=true" + ``` + +=== "/etc/ntfy/server.yml (AWS S3)" + ``` yaml + base-url: "https://ntfy.example.com" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1" + ``` + +=== "/etc/ntfy/server.yml (custom endpoint)" + ``` yaml + base-url: "https://ntfy.example.com" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" + ``` + +Note that the access key and secret key may have to be URL encoded. For instance, a secret key `YmxhY+mxhYmxhC` (note the `+`) should +be encoded as `YmxhY%2BmxhYmxhC` (note the `%2B`), so the URL would be `s3://ACCESS_KEY:YmxhY%2BmxhYmxhC@my-bucket/attachments...`. + +If you experience upload failures with HTTP/2 stream errors (common with DigitalOcean Spaces and some other S3-compatible providers), +add `&disable_http2=true` to force HTTP/1.1 connections. + +!!! info + ntfy.sh is hosted and sponsored by DigitalOcean. I can highly recommend their public cloud offering. It's been rock solid + for 4 years. They offer an S3-compatible storage for $5/month and 250 GB of storage, with 1 TiB of bandwidth. + Also, if you **use [this referral link](https://m.do.co/c/442b929528db), you can get $200 credit**. + +For AWS S3, the IAM user needs the following permissions on the bucket: + +``` json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:ListBucketMultipartUploads" + ], + "Resource": "arn:aws:s3:::BUCKET_NAME" + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:AbortMultipartUpload" + ], + "Resource": "arn:aws:s3:::BUCKET_NAME/*" + } + ] +} +``` ## Access control By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization. -ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles -(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). -Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access. +ntfy's auth implements two roles (`user` and `admin`) and per-topic `read` and `write` permissions using an +[access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). Access control entries can be applied +to users as well as the special everyone user (`*`), which represents anonymous API access. To set up auth, **configure the following options**: -* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested - location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) +* `auth-file` is the user/access database (SQLite); it is created automatically if it doesn't already exist; suggested + location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used). Alternatively, if `database-url` is set, + auth is automatically enabled using PostgreSQL (see [database options](#database-options)). * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance, you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)). @@ -454,7 +882,7 @@ Here's an example: ``` # Comma-separated list NTFY_AUTH_FILE='/var/lib/ntfy/user.db' - NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script' ``` @@ -470,7 +898,8 @@ and access tokens in the `auth-tokens` section (see [access tokens via the confi Here's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script` with the password `backup-script`. The admin user has full access to all topics, while regular user can only -access the `backups` topic with read-write permissions. The `auth-default-access` is set to `deny-all`, which means +access the `backups` topic with read-write permissions. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76` +with the label "My personal token". The `auth-default-access` is set to `deny-all`, which means that all other users and anonymous access are denied by default. === "Config via /etc/ntfy/server.yml" @@ -481,7 +910,7 @@ that all other users and anonymous access are denied by default. - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" - "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user" auth-access: - - "backup-service:backups:rw" + - "backup-script:backups:rw" auth-tokens: - "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token" ``` @@ -491,7 +920,7 @@ that all other users and anonymous access are denied by default. NTFY_AUTH_FILE='/var/lib/ntfy/user.db' NTFY_AUTH_DEFAULT_ACCESS='deny-all' NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user' - NTFY_AUTH_ACCESS='backup-service:backups:rw' + NTFY_AUTH_ACCESS='backup-script:backups:rw' NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token' ``` @@ -590,6 +1019,10 @@ To allow forwarding messages via e-mail, you can configure an **SMTP server for you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. `curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`). +!!! info + On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature, + you must verify your email in the web app's [Account section](https://ntfy.sh/account). + As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the following settings: @@ -597,6 +1030,8 @@ following settings: * `smtp-sender-addr` is the hostname:port of the SMTP server * `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user * `smtp-sender-from` is the e-mail address of the sender +* `smtp-sender-verify` is a flag that forces email recipient verification when enabled. If set to true, + only verified email recipients can be used in the `X-Email` header. Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is configured for `ntfy.sh`): @@ -608,9 +1043,18 @@ configured for `ntfy.sh`): smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-from: "ntfy@ntfy.sh" + smtp-sender-verify: true ``` -Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` +By default, any user (including anonymous users) can send email notifications to any address. To require email +address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and +authenticated users can only send to *literal* email addresses they have verified in their account settings. + +Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary +verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only +governs whether arbitrary literal addresses are allowed. + +Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. ## E-mail publishing @@ -964,7 +1408,7 @@ or the root domain: } ``` -=== "Apache2" +=== "Apache >= 2.4.47" ``` # /etc/apache2/sites-*/ntfy.conf @@ -972,6 +1416,7 @@ or the root domain: ServerName ntfy.sh # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + # Use mod_proxy_http for websocket upgrade ('upgrade=websocket'), which requires Apache (httpd) >= 2.4.47. ProxyPass / http://127.0.0.1:2586/ upgrade=websocket ProxyPassReverse / http://127.0.0.1:2586/ @@ -998,6 +1443,7 @@ or the root domain: Include /etc/letsencrypt/options-ssl-apache.conf # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + # Use mod_proxy_http for websocket upgrade ('upgrade=websocket'), which requires Apache (httpd) >= 2.4.47. ProxyPass / http://127.0.0.1:2586/ upgrade=websocket ProxyPassReverse / http://127.0.0.1:2586/ @@ -1010,10 +1456,72 @@ or the root domain: ``` +=== "Apache < 2.4.47" + ``` + # /etc/apache2/sites-*/ntfy.conf + + + ServerName ntfy.sh + + # Proxy connections to ntfy (requires "a2enmod proxy") + ProxyPass / http://127.0.0.1:2586/ + ProxyPassReverse / http://127.0.0.1:2586/ + + # Enable mod_rewrite (requires "a2enmod rewrite") + RewriteEngine on + # WebSockets support (requires "a2enmod proxy_wstunnel") + # mod_proxy_wstunnel is deprecated as of Apache (httpd) 2.4.47. It also uses more resources since it relies on mod_rewrite. + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteCond %{HTTP:Connection} upgrade [NC] + RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] + + SetEnv proxy-nokeepalive 1 + SetEnv proxy-sendchunked 1 + + # Higher than the max message size of 4096 bytes + LimitRequestBody 102400 + + # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want + # it to work with curl without the annoying https:// prefix (requires "a2enmod alias") + + RedirectMatch permanent "^/([-_A-Za-z0-9]{0,64})$" "https://%{SERVER_NAME}/$1" + + + + + + ServerName ntfy.sh + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem + Include /etc/letsencrypt/options-ssl-apache.conf + + # Proxy connections to ntfy (requires "a2enmod proxy") + ProxyPass / http://127.0.0.1:2586/ + ProxyPassReverse / http://127.0.0.1:2586/ + + # Enable mod_rewrite (requires "a2enmod rewrite") + RewriteEngine on + # WebSockets support (requires "a2enmod proxy_wstunnel") + # mod_proxy_wstunnel is deprecated as of Apache (httpd) 2.4.47. It also uses more resources since it relies on mod_rewrite. + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteCond %{HTTP:Connection} upgrade [NC] + RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] + + SetEnv proxy-nokeepalive 1 + SetEnv proxy-sendchunked 1 + + # Higher than the max message size of 4096 bytes + LimitRequestBody 102400 + + + ``` + === "caddy" ``` # Note that this config is most certainly incomplete. Please help out and let me know what's missing - # via Discord/Matrix or in a GitHub issue. + # via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue. # Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy ntfy.sh, http://nfty.sh { @@ -1029,6 +1537,36 @@ or the root domain: redir @httpget https://{host}{uri} } ``` + +=== "ferron" + ``` kdl + // /etc/ferron.kdl + // Note that this config is most certainly incomplete. Please help out and let me know what's missing + // via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue. + // Note: Ferron automatically handles both HTTP and WebSockets with proxy + + ntfy.sh { + auto_tls + auto_tls_letsencrypt_production + protocols "h1" "h2" "h3" + + proxy "http://127.0.0.1:2586" + + // Redirect HTTP to HTTPS, but only for GET topic addresses, since we want + // it to work with curl without the annoying https:// prefix + + no_redirect_to_https #true + + condition "is_get_topic" { + is_equal "{method}" "GET" + is_regex "{path}" "^/([-_a-z0-9]{0,64}$|docs/|static/)" + } + + if "is_get_topic" { + no_redirect_to_https #false + } + } + ``` ## Firebase (FCM) !!! info @@ -1111,12 +1649,15 @@ a database to keep track of the browser's subscriptions, and an admin email addr - `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 - `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890 -- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` +- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` (not required if `database-url` is set) - `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com` - `web-push-startup-queries` is an optional list of queries to run on startup` - `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`) - `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`) +Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url` +(see [PostgreSQL database](#postgresql-experimental)). + Limitations: - Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_ @@ -1142,9 +1683,10 @@ web-push-file: /var/cache/ntfy/webpush.db web-push-email-address: sysadmin@example.com ``` -The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days, -and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed), -subscriptions are also removed automatically. +The `web-push-file` is used to store the push subscriptions in a local SQLite database. Alternatively, if `database-url` +is set, subscriptions are stored in PostgreSQL and `web-push-file` is not required. Unused subscriptions will send out +a warning after 55 days, and will automatically expire after 60 days (default). If the gateway returns an error +(e.g. 410 Gone when a user has unsubscribed), subscriptions are also removed automatically. The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then. @@ -1231,10 +1773,85 @@ are the easiest), and then configure the following options: * `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 * `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional) After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. +To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is +rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message: + +* `{{.Topic}}` is the topic name +* `{{.Message}}` is the message body +* `{{.Title}}` is the message title +* `{{.Tags}}` is a list of tags +* `{{.Priority}}` is the message priority +* `{{.Sender}}` is the IP address or username of the sender + +Here's an example: + +=== "Custom TwiML (English)" + ``` yaml + twilio-account: "AC12345beefbeef67890beefbeef122586" + twilio-auth-token: "affebeef258625862586258625862586" + twilio-phone-number: "+18775132586" + twilio-verify-service: "VA12345beefbeef67890beefbeef122586" + twilio-call-format: | + + + + Yo yo yo, you should totally check out this message for {{.Topic}}. + {{ if eq .Priority 5 }} + It's really really important, dude. So listen up! + {{ end }} + + {{ if neq .Title "" }} + Bro, it's titled: {{.Title}}. + {{ end }} + + {{.Message}} + + That is all. + + You know who this message is from? It is from {{.Sender}}. + + + See ya! + + ``` + +=== "Custom TwiML (German)" + ``` yaml + twilio-account: "AC12345beefbeef67890beefbeef122586" + twilio-auth-token: "affebeef258625862586258625862586" + twilio-phone-number: "+18775132586" + twilio-verify-service: "VA12345beefbeef67890beefbeef122586" + twilio-call-format: | + + + + Du hast eine Nachricht zum Thema {{.Topic}}. + {{ if eq .Priority 5 }} + Achtung. Die Nachricht ist sehr wichtig. + {{ end }} + + {{ if neq .Title "" }} + Titel der Nachricht: {{.Title}}. + {{ end }} + + Nachricht: + + {{.Message}} + + Ende der Nachricht. + + Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt. + + + Alla mol! + + ``` + ## Message limits There are a few message limits that you can configure: @@ -1305,6 +1922,17 @@ are enabled): * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. +### Topic creation limits +To mitigate topic-enumeration / squatting attacks (where a single source pokes thousands of guessable +topic names to inflate the server's in-memory topic map), there is a per-visitor limit on how many *new* +topics each visitor can cause to be created. Touching topics that already exist in memory does not consume +a token; only first-time insertions do. + +* `visitor-topic-creation-limit-burst` is the initial bucket of new-topic tokens. Set to 0 to disable + the limit entirely. Defaults to 100. +* `visitor-topic-creation-limit-replenish` is the rate at which the bucket is refilled (one new topic per x). + Defaults to 1m. + ### 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) @@ -1531,7 +2159,7 @@ See [Installation for Docker](install.md#docker) for an example of how this coul If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)). -To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated +To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are doing, and/or secure access to the endpoint in your reverse proxy. @@ -1640,78 +2268,84 @@ 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 not support dashes. -| Config option | Env variable | Format | Default | Description | -|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | -| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | -| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | -| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | -| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 | -| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | -| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | -| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | -| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | -| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | -| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | -| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) | -| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | -| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). | -| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | -| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) | -| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) | -| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header | -| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | -| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | -| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | -| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | -| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | -| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | -| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | -| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | -| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | -| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | -| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | -| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | -| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | -| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | -| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | -| `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. | -| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. | -| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header | -| `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 | -| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | -| `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-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | -| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | -| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | -| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | -| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | -| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | -| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | -| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | -| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | -| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | -| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | -| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | -| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | -| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | -| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | -| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | -| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | -| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | -| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate | -| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate | -| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions | -| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | -| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup | -| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 60d | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions. | -| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 55d | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions. | -| `log-format` | `NTFY_LOG_FORMAT` | *string* | `text` | Defines the output format, can be text or json | -| `log-file` | `NTFY_LOG_FILE` | *string* | - | Defines the filename to write logs to. If this is not set, ntfy logs to stderr | -| `log-level` | `NTFY_LOG_LEVEL` | *string* | `info` | Defines the default log level, can be one of trace, debug, info, warn or error | +| Config option | Env variable | Format | Default | Description | +|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | +| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | +| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | +| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | +| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 | +| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | +| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | +| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | +| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [database options](#database-options). | +| `database-replica-urls` | `NTFY_DATABASE_REPLICA_URLS` | *list of strings (connection URLs)* | - | PostgreSQL read replica connection strings. Non-critical read-only queries are distributed across replicas (round-robin) with automatic fallback to primary. Requires `database-url`. | +| `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-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | +| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) | +| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | +| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. 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-access-cache` | `NTFY_AUTH_ACCESS_CACHE` | *bool* | false | Enables an in-memory ACL cache so authorization checks no longer hit the database. Only worth enabling on high-volume servers. | +| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) | +| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) | +| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header | +| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory or S3 URL* | - | Cache directory for attached files, or S3 URL for object storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]`). | +| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | +| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | +| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | +| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | +| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | +| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | +| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | +| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled | +| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | +| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | +| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | +| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | +| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | +| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | +| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | +| `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. | +| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. | +| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header | +| `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 | +| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | +| `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-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | +| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | +| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | +| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | +| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | +| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | +| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | +| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | +| `visitor-topic-creation-limit-burst` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST` | *number* | 100 | Rate limiting: Initial bucket of new topic creations per visitor. 0 disables the limit. | +| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). | +| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | +| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | +| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | +| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | +| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | +| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | +| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | +| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | +| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | +| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | +| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate | +| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate | +| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions | +| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | +| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup | +| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 60d | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions. | +| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 55d | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions. | +| `log-format` | `NTFY_LOG_FORMAT` | *string* | `text` | Defines the output format, can be text or json | +| `log-file` | `NTFY_LOG_FILE` | *string* | - | Defines the filename to write logs to. If this is not set, ntfy logs to stderr | +| `log-level` | `NTFY_LOG_LEVEL` | *string* | `info` | Defines the default log level, can be one of trace, debug, info, warn or error | The format for a *duration* is: `(smhd)`, e.g. 30s, 20m, 1h or 3d. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. @@ -1762,7 +2396,8 @@ OPTIONS: --auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] --auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES] --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] + --auth-access-cache, --auth_access_cache enables the in-memory ACL cache (high-volume servers only) (default: false) [$NTFY_AUTH_ACCESS_CACHE] + --attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$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] @@ -1799,6 +2434,8 @@ OPTIONS: --visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT] --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: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] + --visitor-topic-creation-limit-burst value, --visitor_topic_creation_limit_burst value burst of new topic creations per visitor (0 = disabled) (default: 100) [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST] + --visitor-topic-creation-limit-replenish value, --visitor_topic_creation_limit_replenish value interval at which topic-creation tokens are refilled (one per x) (default: "1m") [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH] --visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4] --visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6] --behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] diff --git a/docs/contact.md b/docs/contact.md new file mode 100644 index 00000000..2be59cb2 --- /dev/null +++ b/docs/contact.md @@ -0,0 +1,46 @@ +# Contact + +This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the +ntfy community. Please choose the appropriate channel based on your needs. + +## Support + +### Community support + +For general questions, feature discussions, and community help, please use one of these public channels: + +| Channel | Link | Description | +|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------| +| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) | +| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) | +| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms | +| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests | + +!!! info "Why public channels?" + Answering questions in public channels benefits the entire community. Other users can learn from the + discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support. + +### Paid support + +If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support +via the following channels: + +| Channel | Contact | Description | +|-----------------------|-----------------------------------------------------|------------------------------------------| +| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers | +| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues | +| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels | + +Please include your ntfy.sh username when contacting support so we can verify your subscription status. + +## Security issues + +If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md). + +## Other inquiries + +For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights +(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh). + +For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please +use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh). diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..620ae257 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,43 @@ +# Contributing + +Thank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer, +translator, or just an enthusiastic user. + +## Code contributions + +If you'd like to contribute code to ntfy: + +1. Check out the [development guide](develop.md) to set up your environment +2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own +3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss + before investing significant time +4. Submit a pull request on GitHub + +All contributions are welcome, from small bug fixes to major features. + +## Translations + +Help make ntfy accessible to users around the world! We use Hosted Weblate for translations: + +- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/) + +You can start translating immediately without any coding knowledge. + +## Documentation + +Found a typo? Want to improve the docs? Documentation contributions are very welcome: + +- Edit any page directly on GitHub using the edit button +- Submit a pull request with your improvements + +## Bug reports and feature requests + +- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) + +Please search existing issues before creating a new one to avoid duplicates. + +## Code of Conduct + +Please be respectful and constructive in all interactions. See the +[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details. + diff --git a/docs/develop.md b/docs/develop.md index 43ac2d4f..01ffbb50 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -2,7 +2,7 @@ Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎 I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't -hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**. +hesitate to reach out via one of the channels listed on the [contact page](contact.md). ## ntfy server The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the @@ -255,6 +255,7 @@ Reference: \ @@ -340,10 +341,6 @@ Then either follow the steps for building with or without Firebase. Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) if you're self-hosting the server. Then run: ``` -# Remove Google dependencies (FCM) -sed -i -e '/google-services/d' build.gradle -sed -i -e '/google-services/d' app/build.gradle - # To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk) ./gradlew assembleFdroidRelease @@ -351,6 +348,8 @@ sed -i -e '/google-services/d' app/build.gradle ./gradlew bundleFdroidRelease ``` +The F-Droid flavor automatically excludes Google Services dependencies. + ### Build Play flavor (FCM) !!! info I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will @@ -441,6 +440,6 @@ To have instant notifications/better notification delivery when using firebase, 1. In XCode, find the NTFY app target. **Not** the NSE app target. 1. Find the Asset/ folder in the project navigator 1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be - found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist" + found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist" After that, you should be all set! diff --git a/docs/examples.md b/docs/examples.md index 10bb014a..ee1de244 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -661,6 +661,8 @@ Add the following function and alias to your `.bashrc` or `.bash_profile`: local token=$(< ~/.ntfy_token) # Securely read the token local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)" local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//') + # for zsh users, use the same sed pattern but get the history differently. + # local last_command=$(history "$HISTCMD" | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//') curl -s -X POST "https://n.example.dev/alerts" \ -H "Authorization: Bearer $token" \ @@ -692,4 +694,4 @@ To test failure notifications: false; alert # Always fails (exit 1) ls --invalid; alert # Invalid option cat nonexistent_file; alert # File not found -``` \ No newline at end of file +``` diff --git a/docs/faq.md b/docs/faq.md index 6ff97cfe..1e8985b7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -71,7 +71,8 @@ The web app is a static website without a backend (other than the ntfy API). All cache and local storage. That means it does not need to be protected with a login screen, and it poses no additional security risk. So technically, it does not need to be disabled. -However, if you still want to disable it, you can do so with the `web-root: disable` option in the `server.yml` file. +However, if you still want, you can require login with the `require-login: true` option, +or disable it with the `web-root: disable` option in the `server.yml` file. Think of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without a proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless. @@ -94,11 +95,11 @@ I would be humbled if you helped me carry the server and developer account costs appreciated. ## Can I email you? Can I DM you on Discord/Matrix? -While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org), -[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally -**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a -[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities. +For community support, please use the public channels listed on the [contact page](contact.md). I generally +**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing) +plan (see [paid support](contact.md#paid-support)), or you are inquiring about business +opportunities (see [other inquiries](contact.md#other-inquiries)). I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions -in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users +in public forums benefits others, since I can link to the discussion at a later point in time, or other users may be able to help out. I hope you understand. diff --git a/docs/index.md b/docs/index.md index 307463ed..6deab9e6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c ## Step 1: Get the app - + To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid. diff --git a/docs/install.md b/docs/install.md index dc50e222..0343a66f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -28,42 +28,130 @@ resources to get started. _I am not affiliated with Kris or Alex, I just liked t Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and deb/rpm packages. +### Download and run +The steps below allow you to download ntfy server and run it in a pinch. But it won't be enough to install it permanently +as a service starting at boot time. + === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz - tar zxvf ntfy_2.15.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.tar.gz + tar zxvf ntfy_2.24.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.24.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz - tar zxvf ntfy_2.15.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.tar.gz + tar zxvf ntfy_2.24.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.24.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz - tar zxvf ntfy_2.15.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.tar.gz + tar zxvf ntfy_2.24.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.24.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz - tar zxvf ntfy_2.15.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.tar.gz + tar zxvf ntfy_2.24.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.24.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` +### Install as a service +If you want to install ntfy server permanently as a service, and your OS/distribution of choice doesn't offer a package, +there are a few more steps to follow. + +Create the ntfy user and group: +```bash +useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy +``` + +Depending on your init system, the following steps will diverge. + +#### On systemd systems +Install the ntfy server unit file (which contains parameters to start the service at boot time): + +=== "x86_64/amd64" + ```bash + sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +=== "armv6" + ```bash + sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +=== "armv7/armhf" + ```bash + sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +=== "arm64" + ```bash + sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +Then notify systemd we have added a new service and start the service: + +```bash +sudo systemctl daemon-reload +sudo systemctl start ntfy +``` + +#### On OpenRC systems +Install the ntfy server service script: + +=== "x86_64/amd64" + ```bash + sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +=== "armv6" + ```bash + sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +=== "armv7/armhf" + ```bash + sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +=== "arm64" + ```bash + sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +Start the ntfy server service: + +```bash +sudo rc-service ntfy start +``` + +Add the ntfy server service to the default runlevel (so that it starts at boot time): + +```bash +sudo rc-update add ntfy default +``` + ## Debian/Ubuntu repository !!! info @@ -71,7 +159,7 @@ deb/rpm packages. The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely go away soon. I suspect I will phase it out some time in early 2026. -Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 6B7C CFDB 962D 4F1E C4AF`): +Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 B6B7 CFDB 962D 4F1E C4AF`): === "x86_64/amd64" ```bash @@ -116,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -124,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -132,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -140,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -150,33 +238,35 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` ## Arch Linux + Community maintained + ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date. @@ -191,7 +281,9 @@ cd ntfysh-bin makepkg -si ``` -## NixOS / Nix +## NixOS / Nix + Community maintained + 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 @@ -199,20 +291,28 @@ nix-env -iA ntfy-sh NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy). +## FreeBSD + Community maintained + +ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`: +``` +pkg install go-ntfy +``` + ## macOS The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. -To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz), extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz -tar zxvf ntfy_2.15.0_darwin_all.tar.gz -sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz > ntfy_2.24.0_darwin_all.tar.gz +tar zxvf ntfy_2.24.0_darwin_all.tar.gz +sudo cp -a ntfy_2.24.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.24.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -221,6 +321,8 @@ ntfy --help development as well. Check out the [build instructions](develop.md) for details. ## Homebrew + Community maintained + To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS), simply run: ``` @@ -228,19 +330,29 @@ brew install ntfy ``` ## Windows -The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip), +The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service. +To install, you can either + +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_windows_amd64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. +* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy` -The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). +Once installed, you can run the ntfy CLI commands like so: -Also available in [Scoop's](https://scoop.sh) Main repository: +``` +ntfy.exe -h +``` -`scoop install ntfy` +The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`) +for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually. -!!! info - There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a - [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. +To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an +elevated command prompt (adjust the path to `ntfy.exe` accordingly): + +``` +sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto +sc start ntfy +``` ## Docker The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should @@ -543,18 +655,18 @@ kubectl apply -k /ntfy cpu: 150m memory: 150Mi volumeMounts: - - mountPath: /etc/ntfy - subPath: server.yml - name: config-volume # generated vie configMapGenerator from kustomization file - - mountPath: /var/cache/ntfy - name: cache-volume #cache volume mounted to persistent volume - volumes: - - name: config-volume - configMap: # uses configmap generator to parse server.yml to configmap - name: server-config - - name: cache-volume - persistentVolumeClaim: # stores /cache/ntfy in defined pv - claimName: ntfy-pvc + - mountPath: /etc/ntfy/server.yml + subPath: server.yml + name: config-volume # generated via configMapGenerator from kustomization file + - mountPath: /var/cache/ntfy + name: cache-volume # cache volume mounted to persistent volume + volumes: + - name: config-volume + configMap: # uses configmap generator to parse server.yml to configmap + name: server-config + - name: cache-volume + persistentVolumeClaim: # stores /cache/ntfy in defined pv + claimName: ntfy-pvc ``` === "ntfy-pvc.yaml" diff --git a/docs/integrations.md b/docs/integrations.md index 4613cb58..57e0a85e 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -42,6 +42,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong. - [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader - [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform +- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform ## Integration via HTTP/SMTP/etc. @@ -88,8 +89,9 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy - [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications - [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte +- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#) - [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic -- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop +- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop - [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy - [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications - [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11 @@ -97,6 +99,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in. - [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails - [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++. +- [Ntfy App](https://github.com/rubix-studios-pty-ltd/ntfy-app) - Tauri/Rust desktop client for Windows, Linux and MacOS with push notifications. ## Projects + scripts @@ -106,6 +109,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh) - [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell) - [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go) +- [oci-notifications-ntfy-relay](https://github.com/Ryan02I5/oci-notifications-ntfy-relay) - Minimal OCI Notifications / Oracle Functions relay to ntfy topics (Python) - [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell) - [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs) - [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell) @@ -180,9 +184,16 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy - [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy. - [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go) +- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go) +- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) +- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS) +- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python) +- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services +- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React). ## Blog + forum posts +- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 5/2026 - [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025 - [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025 - [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025 @@ -300,7 +311,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!** | URL | Country | |---------------------------------------------------|--------------------| | [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States | -| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France | +| [ntfy.tedomum.fr](https://ntfy.tedomum.fr/) | 🇫🇷 France | | [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland | | [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany | | [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | diff --git a/docs/privacy.md b/docs/privacy.md index f89f9aaa..322e4f34 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,12 +1,198 @@ # Privacy policy -I love free software, and I'm doing this because it's fun. I have no bad intentions, and **I will -never monetize or sell your information, and this service and software will always stay free and open.** +**Last updated:** June 15, 2026 -Neither the server nor the app record any personal information, or share any of the messages and topics with -any outside service. All data is exclusively used to make the service function properly. The only external service -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. +This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information +when you use the ntfy.sh service, web app, and mobile applications (Android and iOS). -For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics -or messages, though typically this is turned off. +## Our commitment to privacy + +We love free software, and we're doing this because it's fun. We have no bad intentions, and **we will +never monetize or sell your information**. The ntfy service and software will always stay free and open source. +If you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md). + +## Information we collect + +### Account information (optional) + +If you create an account on ntfy.sh, we collect: + +- **Username** - A unique identifier you choose +- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password) +- **Email address** - If you add an email address to your account for account recovery and password resets, for use + with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email + addresses you add to your account are verified by sending a confirmation link. +- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call) + +You can use ntfy without creating an account. Anonymous usage is fully supported. + +### Messages and notifications + +- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support + message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires. +- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted. +- **Topic names** - The topic names you publish to or subscribe to are processed by our servers. + +### Technical information + +- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes, + though this is typically turned off. +- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time, + and the IP address of the last access. +- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription + endpoint to deliver notifications. + +### Billing information (paid plans only) + +If you subscribe to a paid plan, payment processing is handled by Stripe. We store: + +- Stripe customer ID +- Subscription status and billing period + +We do not store your credit card numbers or payment details directly. These are handled entirely by Stripe. + +## Third-party services + +To provide the ntfy.sh service, we use the following third-party services: + +### Firebase Cloud Messaging (FCM) + +We use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you +receive a notification through the mobile apps (Google Play or App Store versions): + +- Message metadata and content may be transmitted through Google's FCM infrastructure +- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data + +**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of +the Android app and use a self-hosted server, or use the instant delivery feature with your own server. + +### Twilio (phone calls) + +If you use the phone call notification feature (`X-Call` header), we use Twilio to: + +- Make voice calls to your verified phone number +- Send SMS or voice calls for phone number verification + +Your phone number is shared with Twilio to deliver these services. Twilio's +[privacy policy](https://www.twilio.com/legal/privacy) applies. + +### Amazon SES (email delivery) + +If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email +address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The +recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's +[privacy policy](https://aws.amazon.com/privacy/) applies. + +### Stripe (payments) + +If you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly +by Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy). + +Note: We have explicitly disabled Stripe's telemetry features in our integration. + +### Web push providers + +If you enable browser notifications in the ntfy web app, push messages are delivered through your browser +vendor's push service: + +- Google (Chrome) +- Mozilla (Firefox) +- Apple (Safari) +- Microsoft (Edge) + +Your browser's push subscription endpoint is shared with these providers to deliver notifications. + +## Mobile applications + +### Android app + +The Android app is available from two sources: + +- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is + **explicitly disabled** in our app. +- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain + a direct connection to the server. + +The Android app stores the following data locally on your device: + +- Subscribed topics and their settings +- Cached notifications +- User credentials (if you add a server with authentication) +- Application logs (for debugging, stored locally only) + +### iOS app + +The iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications. +The app stores the following data locally on your device: + +- Subscribed topics +- Cached notifications +- User credentials (if configured) + +## Web application + +The ntfy web app is a static website that stores all data locally in your browser: + +- **IndexedDB** - Stores your subscriptions and cached notifications +- **Local Storage** - Stores your preferences and session information + +No cookies are used for tracking. The web app does not have a backend beyond the ntfy API. + +## Data retention + +| Data type | Retention period | +|------------------------|---------------------------------------------------| +| Messages | 12 hours (configurable by server operators) | +| Attachments | 3 hours (configurable by server operators) | +| User accounts | Until you delete your account | +| Access tokens | Until you revoke them or delete your account | +| Email addresses | Until you remove them or delete your account | +| Phone numbers | Until you remove them or delete your account | +| Web push subscriptions | 60 days of inactivity, then automatically removed | +| Server logs | Varies; debugging logs are typically temporary | + +## Self-hosting + +If you prefer complete control over your data, you can [self-host your own ntfy server](install.md). +When self-hosting: + +- You control all data storage and retention +- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations +- No data is shared with ntfy.sh or any third party (unless you configure those integrations) + +The server and all apps are fully open source: + +- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy) +- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android) +- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios) + +## Data security + +- All connections to ntfy.sh are encrypted using TLS/HTTPS +- Passwords are hashed using bcrypt before storage +- Access tokens are generated using cryptographically secure random values +- The server does not log message content by default + +## Your rights + +You have the right to: + +- **Access** - View your account information and data +- **Delete** - Delete your account and associated data via the web app +- **Export** - Your messages are available via the API while cached + +To delete your account, use the account settings in the web app or contact us. + +## Changes to this policy + +We may update this privacy policy from time to time. Changes will be posted on this page with an updated +"Last updated" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md). + +For significant changes, we may provide additional notice on Discord/Matrix or through the +[announcements](https://ntfy.sh/announcements) ntfy topic. + +## Contact + +For privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh). + +For all other contact options, see the [contact page](contact.md). diff --git a/docs/publish.md b/docs/publish.md index ce3500e8..f8328f84 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1,7 +1,7 @@ # Publishing -Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](install.md). Topics are created on the fly by -subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick -something that's not easily guessable. +Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)). +Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick +something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator). Here's an example showing how to publish a simple message using a POST request: @@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
Notification using a click action, a user action, with an external image attachment and forwarded via email
+## Picking a topic +Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may +only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long. + +Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser: + +
+
+Topic name generator + +
+
+
+
+ + +
+
Spaces and characters other than letters, numbers, - and _ are removed automatically as you type. Names are capped at 64 characters.
+
+
+
+Your topic: +
+

+
+
+
+
+Your topic URL: +
+
https://ntfy.sh/
+ +
+
+
+
+
+ ## Message title _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -641,7 +679,7 @@ You can format messages using [Markdown](https://www.markdownguide.org/basic-syn By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`. -As of today, **Markdown is only supported in the web app.** Here's an example of how to enable Markdown formatting: +Here's an example of how to enable Markdown formatting: === "Command line (curl)" ``` @@ -705,8 +743,8 @@ As of today, **Markdown is only supported in the web app.** Here's an example of === "Python" ``` python requests.post("https://ntfy.sh/mytopic", - data="Look ma, **bold text**, *italics*, ..." - headers={ "Markdown": "yes" })) + data="Look ma, **bold text**, *italics*, ...", + headers={ "Markdown": "yes" }) ``` === "PHP" @@ -727,61 +765,65 @@ Here's what that looks like in the web app:
Markdown formatting in the web app
-## Scheduled delivery +## Click action _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 -reminders or even to execute commands at a later date (if your subscriber acts on messages). +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 +the web browser (or the app) and open the website. -Usage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`, -`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`, -`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`, -[and more](https://github.com/olebedev/when)). +To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`). +If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled +by another app, the responsible app may open. -As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured -with the `message-delay-limit` option). +Examples: -For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours -after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled -to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally, -[turning off server-side caching](#message-caching) is not possible in combination with this feature. +* `http://` or `https://` will open your browser (or an app if it registered for a URL) +* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com` +* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA` +* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats` +* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..` +* ... + +Here's an example that will open Reddit when the notification is clicked: === "Command line (curl)" ``` - curl -H "At: tomorrow, 10am" -d "Good morning" ntfy.sh/hello - curl -H "In: 30min" -d "It's 30 minutes later now" ntfy.sh/reminder - curl -H "Delay: 1639194738" -d "Unix timestamps are awesome" ntfy.sh/itsaunixsystem + curl \ + -d "New messages on Reddit" \ + -H "Click: https://www.reddit.com/message/messages" \ + ntfy.sh/reddit_alerts ``` === "ntfy CLI" ``` ntfy publish \ - --at="tomorrow, 10am" \ - hello "Good morning" + --click="https://www.reddit.com/message/messages" \ + reddit_alerts "New messages on Reddit" ``` === "HTTP" ``` http - POST /hello HTTP/1.1 + POST /reddit_alerts HTTP/1.1 Host: ntfy.sh - At: tomorrow, 10am + Click: https://www.reddit.com/message/messages - Good morning + New messages on Reddit ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/hello', { + fetch('https://ntfy.sh/reddit_alerts', { method: 'POST', - body: 'Good morning', - headers: { 'At': 'tomorrow, 10am' } + body: 'New messages on Reddit', + headers: { 'Click': 'https://www.reddit.com/message/messages' } }) ``` === "Go" ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning")) - req.Header.Set("At", "tomorrow, 10am") + req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit")) + req.Header.Set("Click", "https://www.reddit.com/message/messages") http.DefaultClient.Do(req) ``` @@ -789,281 +831,207 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al ``` powershell $Request = @{ Method = "POST" - URI = "https://ntfy.sh/hello" - Headers = @{ - At = "tomorrow, 10am" - } - Body = "Good morning" + URI = "https://ntfy.sh/reddit_alerts" + Headers = @{ Click="https://www.reddit.com/message/messages" } + Body = "New messages on Reddit" } Invoke-RestMethod @Request ``` - + === "Python" ``` python - requests.post("https://ntfy.sh/hello", - data="Good morning", - headers={ "At": "tomorrow, 10am" }) + requests.post("https://ntfy.sh/reddit_alerts", + data="New messages on Reddit", + headers={ "Click": "https://www.reddit.com/message/messages" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/backups', false, stream_context_create([ + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n" . - "At: tomorrow, 10am", - 'content' => 'Good morning' + "Click: https://www.reddit.com/message/messages", + 'content' => 'New messages on Reddit' ] ])); ``` -Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**): +## Icons +_Supported on:_ :material-android: - - -
- - - - - - - -
Delay/At/In headerMessage will be delivered atExplanation
30m12/10/2021, 9:30am30 minutes from now
2 hours12/10/2021, 11:30am2 hours from now
1 day12/11/2021, 9am24 hours from now
10am12/10/2021, 10amToday at 10am (same day, because it's only 9am)
8am12/11/2021, 8amTomorrow at 8am (because it's 9am already)
163915200012/10/2021, 11am (EST) Today at 11am (EST)
-
+You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query +parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download +the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are +cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**. -## 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 -a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support. - -To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without -any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are -also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all -[supported parameters and headers](#list-of-all-parameters) for details. - -For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message -(aka trigger the webhook): +Here's an example showing how to include an icon: === "Command line (curl)" ``` - curl ntfy.sh/mywebhook/trigger - ``` - -=== "ntfy CLI" - ``` - ntfy trigger mywebhook - ``` - -=== "HTTP" - ``` http - GET /mywebhook/trigger HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/mywebhook/trigger') - ``` - -=== "Go" - ``` go - http.Get("https://ntfy.sh/mywebhook/trigger") - ``` - -=== "PowerShell" - ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/trigger" - ``` - -=== "Python" - ``` python - requests.get("https://ntfy.sh/mywebhook/trigger") - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mywebhook/trigger'); - ``` - -To add a custom message, simply append the `message=` URL parameter. And of course you can set the -[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. -For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters). - -Here's an example with a custom message, tags and a priority: - -=== "Command line (curl)" - ``` - curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + curl \ + -H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + -H "Title: Kodi: Resuming Playback" \ + -H "Tags: arrow_forward" \ + -d "The Wire, S01E01" \ + ntfy.sh/tvshows ``` === "ntfy CLI" ``` ntfy publish \ - -p 5 --tags=warning,skull \ - mywebhook "Webhook triggered" + --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + --title="Kodi: Resuming Playback" \ + --tags="arrow_forward" \ + tvshows \ + "The Wire, S01E01" ``` === "HTTP" ``` http - GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1 + POST /tvshows HTTP/1.1 Host: ntfy.sh + Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png + Tags: arrow_forward + Title: Kodi: Resuming Playback + + The Wire, S01E01 ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull') + fetch('https://ntfy.sh/tvshows', { + method: 'POST', + headers: { + 'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png', + 'Title': 'Kodi: Resuming Playback', + 'Tags': 'arrow_forward' + }, + body: "The Wire, S01E01" + }) ``` === "Go" ``` go - http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01")) + req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png") + req.Header.Set("Tags", "arrow_forward") + req.Header.Set("Title", "Kodi: Resuming Playback") + http.DefaultClient.Do(req) ``` === "PowerShell" ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/tvshows" + Headers = @{ + Title = "Kodi: Resuming Playback" + Tags = "arrow_forward" + Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + } + Body = "The Wire, S01E01" + } + Invoke-RestMethod @Request ``` === "Python" ``` python - requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + requests.post("https://ntfy.sh/tvshows", + data="The Wire, S01E01", + headers={ + "Title": "Kodi: Resuming Playback", + "Tags": "arrow_forward", + "Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); + file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Title: Kodi: Resuming Playback\r\n" . + "Tags: arrow_forward\r\n" . + "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png", + ], + 'content' => "The Wire, S01E01" + ])); ``` -## Message templating +Here's an example of how it will look on Android: + +
+ ![file attachment](static/img/android-screenshot-icon.png){ width=500 } +
Custom icon from an external URL
+
+ +## Attachments _Supported on:_ :material-android: :material-apple: :material-firefox: -Templating lets you **format a JSON message body into human-friendly message and title text** using -[Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/), -[here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and -[here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when -**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks), -[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/), -[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks. +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. -Instead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated -message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body -is valid JSON). +There are two different ways to send attachments: -You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`): +* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3` +* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` -* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`, - `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name. - See [pre-defined templates](#pre-defined-templates) for more details. -* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`) - will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). - See [custom templates](#custom-templates) for more details. -* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) - will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template. - See [inline templating](#inline-templating) for more details. +### Attach local file +To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater +than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically +detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files +as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases +`Filename`, `File` or `f`). -To learn the basics of Go's templating language, please see [template syntax](#template-syntax). +By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). +Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app +to auto-download it. Please also check out the [other limits below](#limitations). -### Pre-defined templates - -When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the -message and/or title based on one of the built-in pre-defined templates. - -The following **pre-defined templates** are available: - -* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml). -* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml). -* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml). - -To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`, -can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details. - -Here's an example of how to use the **pre-defined `github` template**: - -First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`. -
- ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 } -
GitHub webhook configuration
-
- -After that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules -and you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository: - -
- ![pre-defined template](static/img/android-screenshot-template-predefined.png){ width=500 } -
Receiving a webhook, formatted using the pre-defined "github" template
-
- -### Custom templates - -To define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`) -and set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension). - -For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or -the query parameter `?template=myapp` to use it. - -Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys, -which are interpreted as Go templates. - -Here's an **example custom template**: - -=== "Custom template (/etc/ntfy/templates/myapp.yml)" - ```yaml - title: | - {{- if eq .status "firing" }} - {{- if gt .percent 90.0 }}🚨 Critical alert - {{- else }}⚠️ Alert{{- end }} - {{- else if eq .status "resolved" }} - ✅ Alert resolved - {{- end }} - message: | - Status: {{ .status }} - Type: {{ .type | upper }} ({{ .percent }}%) - Server: {{ .server }} - ``` - -Once you have the template file in place, you can send the payload to your topic using the `X-Template` -header or query parameter: +Here's an example showing how to upload an image: === "Command line (curl)" ``` - echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ - curl -sT- "https://ntfy.example.com/mytopic?template=myapp" + curl \ + -T flower.jpg \ + -H "Filename: flower.jpg" \ + ntfy.sh/flowers ``` === "ntfy CLI" ``` - echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ - ntfy publish --template=myapp https://ntfy.example.com/mytopic + ntfy publish \ + --file=flower.jpg \ + flowers ``` === "HTTP" ``` http - POST /mytopic?template=myapp HTTP/1.1 - Host: ntfy.example.com - - { - "status": "firing", - "type": "cpu", - "server": "ntfy.sh", - "percent": 99 - } + PUT /flowers HTTP/1.1 + Host: ntfy.sh + Filename: flower.jpg + Content-Type: 52312 + + (binary JPEG data) ``` === "JavaScript" ``` javascript - fetch('https://ntfy.example.com/mytopic?template=myapp', { - method: 'POST', - body: '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + fetch('https://ntfy.sh/flowers', { + method: 'PUT', + body: document.getElementById("file").files[0], + headers: { 'Filename': 'flower.jpg' } }) ``` === "Go" ``` go - payload := `{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}` - req, _ := http.NewRequest("POST", "https://ntfy.example.com/mytopic?template=myapp", strings.NewReader(payload)) + file, _ := os.Open("flower.jpg") + req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file) + req.Header.Set("Filename", "flower.jpg") http.DefaultClient.Do(req) ``` @@ -1071,281 +1039,88 @@ header or query parameter: ``` powershell $Request = @{ Method = "POST" - Uri = "https://ntfy.example.com/mytopic?template=myapp" - Body = '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + Uri = "ntfy.sh/flowers" + InFile = "flower.jpg" + Headers = @{"Filename" = "flower.jpg"} } Invoke-RestMethod @Request ``` === "Python" ``` python - requests.post("https://ntfy.example.com/mytopic?template=myapp", - json={"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}) + requests.put("https://ntfy.sh/flowers", + data=open("flower.jpg", 'rb'), + headers={ "Filename": "flower.jpg" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([ + file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([ 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json", - 'content' => '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + 'method' => 'PUT', + 'header' => + "Content-Type: application/octet-stream\r\n" . // Does not matter + "Filename: flower.jpg", + 'content' => file_get_contents('flower.jpg') // Dangerous for large files ] ])); ``` -Which will result in a notification that looks like this: +Here's what that looks like on Android:
- ![notification from custom JSON webhook template](static/img/android-screenshot-template-custom.png){ width=500 } -
JSON webhook, transformed using a custom template
+ ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 } +
Image attachment sent from a local file
-### Inline templating +### Attach file from a URL +Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted. +This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are +externally hosted, the expiration or size limits from above do not apply here. -When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your -webhook payload. +To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) +to specify the attachment URL. It can be any type of file. -Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh). -Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead, -if you control the ntfy server, as templates are much easier to maintain. +ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a +filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its +aliases `Filename`, `File` or `f`). -Here's an **example for a Grafana alert**: - -
- ![notification with actions](static/img/android-screenshot-template.jpg){ width=500 } -
Grafana webhook, formatted using templates
-
- -This was sent using the following templates and payloads - -=== "Message template" - ``` - {{range .alerts}} - {{.annotations.summary}} - - Values: - {{range $k,$v := .values}} - - {{$k}}={{$v}} - {{end}} - {{end}} - ``` - -=== "Title template" - ``` - {{.title}} - ``` - -=== "Encoded webhook URL" - ``` - # Additional URL encoding (see https://www.urlencoder.org/) is necessary for Grafana, - # and may be required for other tools too - - https://ntfy.sh/mytopic?tpl=1&t=%7B%7B.title%7D%7D&m=%7B%7Brange%20.alerts%7D%7D%7B%7B.annotations.summary%7D%7D%5Cn%5CnValues%3A%5Cn%7B%7Brange%20%24k%2C%24v%20%3A%3D%20.values%7D%7D-%20%7B%7B%24k%7D%7D%3D%7B%7B%24v%7D%7D%5Cn%7B%7Bend%7D%7D%7B%7Bend%7D%7D - ``` - -=== "Grafana-sent payload" - ``` - {"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"} - ``` - -Here's an **easier example with a shorter JSON payload**: +Here's an example showing how to attach an APK file: === "Command line (curl)" ``` - # To use { and } in the URL without encoding, we need to turn off - # curl's globbing using --globoff - curl \ - --globoff \ - -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \ - 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}' + -X POST \ + -H "Attach: https://f-droid.org/F-Droid.apk" \ + ntfy.sh/mydownloads + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --attach="https://f-droid.org/F-Droid.apk" \ + mydownloads ``` === "HTTP" ``` http - POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1 + POST /mydownloads HTTP/1.1 Host: ntfy.sh - - {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} + Attach: https://f-droid.org/F-Droid.apk ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', { + fetch('https://ntfy.sh/mydownloads', { method: 'POST', - body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' + headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' } }) ``` === "Go" ``` go - body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}` - uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" - req, _ := http.NewRequest("POST", uri, strings.NewReader(body)) - http.DefaultClient.Do(req) - ``` - - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" - Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' - ContentType = "application/json" - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.post( - "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", - data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' - ) - ``` - -=== "PHP" - ``` php-inline - file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json", - 'content' => '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' - ] - ])); - ``` - -This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding -`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message -`Error message: Disk has run out of space`. - -### Template syntax -ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, -yet also one of the worst templating languages out there. - -You can use the following features in your templates: - -* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` -* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) -* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) - -A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test -your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). - -### Template functions -ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), -thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload. - -Below are the functions that are available to use inside your message/title templates. - -* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc. -* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. -* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc. -* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep` -* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf` -* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc. -* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` -* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc. -* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc. -* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. -* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc. -* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` -* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail` -* Advanced Functions - * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc. - * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc. - * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin` - - -## 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/)), -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. - -To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below -the example. - -!!! info - To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're - POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect). - -Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter -is the only required one: - -=== "Command line (curl)" - ``` - curl ntfy.sh \ - -d '{ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }' - ``` - -=== "HTTP" - ``` http - POST / HTTP/1.1 - Host: ntfy.sh - - { - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - } - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh', { - method: 'POST', - body: JSON.stringify({ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }) - }) - ``` - -=== "Go" - ``` go - // You should probably use json.Marshal() instead and make a proper struct, - // or even just use req.Header.Set() like in the other examples, but for the - // sake of the example, this is easier. - - body := `{ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }` - req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file) + req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk") http.DefaultClient.Do(req) ``` @@ -1353,87 +1128,34 @@ is the only required one: ``` powershell $Request = @{ Method = "POST" - URI = "https://ntfy.sh" - Body = ConvertTo-JSON @{ - Topic = "mytopic" - Title = "Low disk space alert" - Message = "Disk space is low at 5.1 GB" - Priority = 4 - Attach = "https://filesrv.lan/space.jpg" - FileName = "diskspace.jpg" - Tags = @("warning", "cd") - Click = "https://homecamera.lan/xasds1h2xsSsa/" - Actions = @( - @{ - Action = "view" - Label = "Admin panel" - URL = "https://filesrv.lan/admin" - } - ) - } - ContentType = "application/json" + URI = "https://ntfy.sh/mydownloads" + Headers = @{ Attach="https://f-droid.org/F-Droid.apk" } } Invoke-RestMethod @Request ``` === "Python" ``` python - requests.post("https://ntfy.sh/", - data=json.dumps({ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }) - ) + requests.put("https://ntfy.sh/mydownloads", + headers={ "Attach": "https://f-droid.org/F-Droid.apk" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/', false, stream_context_create([ + file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([ 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json", - 'content' => json_encode([ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [["action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" ]] - ]) + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Attach: https://f-droid.org/F-Droid.apk", ] ])); ``` -The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md) -(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of -all the supported fields: - -| Field | Required | Type | Example | Description | -|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------| -| `topic` | ✔️ | *string* | `topic1` | Target topic name | -| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | -| `title` | - | *string* | `Some title` | Message [title](#message-title) | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | -| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | -| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | -| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | -| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | -| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | -| `filename` | - | *string* | `file.jpg` | File name of the attachment | -| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | -| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | -| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | +
+ ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 } +
File attachment sent from an external URL
+
## Action buttons _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -1450,6 +1172,7 @@ As of today, the following actions are supported: * [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent when the action button is tapped (only supported on Android) * [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped +* [`copy`](#copy-to-clipboard): Copies a given value to the clipboard when the action button is tapped Here's an example of what a notification with actions can look like: @@ -1480,9 +1203,12 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions` Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons. -The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and -`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not -work. If they don't, use the [JSON array format](#using-a-json-array) instead. +Each action type has a short format where some key prefixes can be omitted: + +* [`view`](#open-websiteapp): `view,