diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3bf2a126 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +dist +*/node_modules +Dockerfile* diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..23002306 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,11 @@ +# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view + +# Run prettier (https://github.com/binwiederhier/ntfy/pull/746) +6f6a2d1f693070bf72e89d86748080e4825c9164 +c87549e71a10bc789eac8036078228f06e515a8e +ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 +2e27f58963feb9e4d1c573d4745d07770777fa7d + +# Run eslint (https://github.com/binwiederhier/ntfy/pull/748) +f558b4dbe9bb5b9e0e87fada1215de2558353173 +8319f1cf26113167fb29fe12edaff5db74caf35f diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..e1eb0619 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [binwiederhier] +liberapay: ntfy diff --git a/.github/ISSUE_TEMPLATE/1_bug_report.md b/.github/ISSUE_TEMPLATE/1_bug_report.md new file mode 100644 index 00000000..90ff2b27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_bug_report.md @@ -0,0 +1,26 @@ +--- +name: 🐛 Bug Report +about: Report any errors and problems +title: '' +labels: '🪲 bug' +assignees: '' + +--- + +:lady_beetle: **Describe the bug** + + +:computer: **Components impacted** + + +:bulb: **Screenshots and/or logs** + + +:crystal_ball: **Additional context** + diff --git a/.github/ISSUE_TEMPLATE/2_enhancement_request.md b/.github/ISSUE_TEMPLATE/2_enhancement_request.md new file mode 100644 index 00000000..790ded12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_enhancement_request.md @@ -0,0 +1,26 @@ +--- +name: 💡 Feature/Enhancement Request +about: Got a great idea? Let us know! +title: '' +labels: 'enhancement' +assignees: '' + +--- + + + +:bulb: **Idea** + + +:computer: **Target components** + + + diff --git a/.github/ISSUE_TEMPLATE/3_tech_support.md b/.github/ISSUE_TEMPLATE/3_tech_support.md new file mode 100644 index 00000000..82afe7a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_tech_support.md @@ -0,0 +1,21 @@ +--- +name: 🆘 I need help with ... +about: Installing ntfy, configuring the app, etc. +title: '' +labels: 'tech-support' +assignees: '' + +--- + + + diff --git a/.github/ISSUE_TEMPLATE/4_question.md b/.github/ISSUE_TEMPLATE/4_question.md new file mode 100644 index 00000000..9d930ef0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4_question.md @@ -0,0 +1,21 @@ +--- +name: ❓ Question +about: Ask a question about ntfy +title: '' +labels: 'question' +assignees: '' + +--- + + + +:question: **Question** + diff --git a/.github/images/logo.png b/.github/images/logo.png new file mode 100644 index 00000000..351db4d7 Binary files /dev/null and b/.github/images/logo.png differ diff --git a/.github/images/screenshot-curl.png b/.github/images/screenshot-curl.png new file mode 100644 index 00000000..535d0830 Binary files /dev/null and b/.github/images/screenshot-curl.png differ diff --git a/.github/images/screenshot-phone-detail.jpg b/.github/images/screenshot-phone-detail.jpg new file mode 100644 index 00000000..2cd3b2fe Binary files /dev/null and b/.github/images/screenshot-phone-detail.jpg differ diff --git a/.github/images/screenshot-phone-main.jpg b/.github/images/screenshot-phone-main.jpg new file mode 100644 index 00000000..5caeee14 Binary files /dev/null and b/.github/images/screenshot-phone-main.jpg differ diff --git a/.github/images/screenshot-phone-notification.jpg b/.github/images/screenshot-phone-notification.jpg new file mode 100644 index 00000000..7924c6fd Binary files /dev/null and b/.github/images/screenshot-phone-notification.jpg differ diff --git a/.github/images/screenshot-web-detail.png b/.github/images/screenshot-web-detail.png new file mode 100644 index 00000000..5b32aa22 Binary files /dev/null and b/.github/images/screenshot-web-detail.png differ diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 00000000..de22292a --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,30 @@ +name: build +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - + name: Checkout code + uses: actions/checkout@v3 + - + name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.x' + - + name: Install node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + - + name: Install dependencies + run: make build-deps-ubuntu + - + name: Build all the things + run: make build + - + name: Print build results and checksums + run: make cli-build-results diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 00000000..6991dea6 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,36 @@ +name: docs +on: + push: + branches: + - main +jobs: + publish-docs: + runs-on: ubuntu-latest + steps: + - + name: Checkout ntfy code + uses: actions/checkout@v3 + - + name: Checkout docs pages code + uses: actions/checkout@v3 + with: + repository: binwiederhier/ntfy-docs.github.io + path: build/ntfy-docs.github.io + token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}} + # Expires after 1 year, re-generate via + # User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token + - + name: Build docs + run: make docs + - + name: Copy generated docs + run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/ + - + name: Publish docs + run: | + cd build/ntfy-docs.github.io + git config user.name "GitHub Actions Bot" + git config user.email "" + git add docs/ + git commit -m "Updated docs" + git push origin main diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..b61e3361 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,41 @@ +name: release +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' +jobs: + release: + runs-on: ubuntu-latest + steps: + - + name: Checkout code + uses: actions/checkout@v3 + - + name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.x' + - + name: Install node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + - + name: Docker login + uses: docker/login-action@v2 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - + name: Install dependencies + run: make build-deps-ubuntu + - + name: Build and publish + run: make release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - + name: Print build results and checksums + run: make cli-build-results diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..f76862a9 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,39 @@ +name: test +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - + name: Checkout code + uses: actions/checkout@v3 + - + name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.x' + - + name: Install node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + - + name: Install dependencies + run: make build-deps-ubuntu + - + name: Build docs (required for tests) + run: make docs + - + name: Build web app (required for tests) + run: make web + - + name: Run tests, formatting, vetting and linting + run: make check + - + name: Run coverage + run: make coverage + - + name: Upload coverage to codecov.io + run: make coverage-upload diff --git a/.gitignore b/.gitignore index a88775f3..b60c9b23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,16 @@ dist/ +dev-dist/ +build/ .idea/ +.vscode/ +*.swp +server/docs/ +server/site/ +tools/fbsend/fbsend +playground/ +secrets/ *.iml +node_modules/ +.DS_Store +__pycache__ +web/dev-dist/ \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..6cccd8f2 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,28 @@ +tasks: + - name: docs + before: make docs-deps + command: mkdocs serve + - name: binary + before: | + npm install --global nodemon + make cli-deps-static-sites + command: | + nodemon --watch './**/*.go' --ext go --signal SIGTERM --exec "CGO_ENABLED=1 go run main.go serve --listen-http :2586 --debug --base-url $(gp url 2586)" + openMode: split-right + - name: web + before: make web-deps + command: cd web && npm start + openMode: split-right + +vscode: + extensions: + - golang.go + - ms-azuretools.vscode-docker + +ports: + - name: docs + port: 8000 + - name: binary + port: 2586 + - name: web + port: 3000 \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 2090144d..3c3aa490 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,19 +1,77 @@ before: hooks: - go mod download + - go mod tidy builds: - - binary: ntfy + - + id: ntfy_linux_amd64 + binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - goos: - - linux - goarch: - - amd64 + tags: [sqlite_omit_load_extension,osusergo,netgo] + ldflags: + - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [linux] + goarch: [amd64] + - + id: ntfy_linux_armv6 + binary: ntfy + env: + - CGO_ENABLED=1 # required for go-sqlite3 + - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi + tags: [sqlite_omit_load_extension,osusergo,netgo] + ldflags: + - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [linux] + goarch: [arm] + goarm: [6] + - + id: ntfy_linux_armv7 + binary: ntfy + env: + - CGO_ENABLED=1 # required for go-sqlite3 + - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi + tags: [sqlite_omit_load_extension,osusergo,netgo] + ldflags: + - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [linux] + goarch: [arm] + goarm: [7] + - + id: ntfy_linux_arm64 + binary: ntfy + env: + - CGO_ENABLED=1 # required for go-sqlite3 + - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu + tags: [sqlite_omit_load_extension,osusergo,netgo] + ldflags: + - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [linux] + goarch: [arm64] + - + 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 + ldflags: + - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [windows] + goarch: [amd64] + - + id: ntfy_darwin_all + binary: ntfy + env: + - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 + tags: [noserver] # don't include server files + ldflags: + - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + goos: [darwin] + goarch: [amd64, arm64] # will be combined to "universal binary" (see below) nfpms: - package_name: ntfy - file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}" - homepage: https://heckel.io/ntfy + homepage: https://git.zio.sh/astra/ntfy/v2 maintainer: Philipp C. Heckel description: Simple pub-sub notification service license: Apache 2.0 @@ -22,24 +80,69 @@ nfpms: - rpm bindir: /usr/bin contents: - - src: config/config.yml - dst: /etc/ntfy/config.yml - type: config - - src: config/ntfy.service + - src: server/server.yml + dst: /etc/ntfy/server.yml + type: "config|noreplace" + - src: server/ntfy.service dst: /lib/systemd/system/ntfy.service + - src: client/client.yml + dst: /etc/ntfy/client.yml + type: "config|noreplace" + - src: client/ntfy-client.service + dst: /lib/systemd/system/ntfy-client.service + - dst: /var/cache/ntfy + type: dir + - dst: /var/cache/ntfy/attachments + type: dir + - dst: /var/lib/ntfy + type: dir + - dst: /usr/share/ntfy/logo.png + src: web/public/static/images/ntfy.png scripts: + preinstall: "scripts/preinst.sh" + postinstall: "scripts/postinst.sh" + preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - + id: ntfy_linux + builds: + - ntfy_linux_amd64 + - ntfy_linux_armv6 + - ntfy_linux_armv7 + - ntfy_linux_arm64 wrap_in_directory: true files: - LICENSE - README.md - - config/config.yml - - config/ntfy.service - replacements: - 386: i386 - amd64: x86_64 + - server/server.yml + - server/ntfy.service + - client/client.yml + - client/ntfy-client.service + - + id: ntfy_windows + builds: + - ntfy_windows_amd64 + format: zip + wrap_in_directory: true + files: + - LICENSE + - README.md + - client/client.yml + - + id: ntfy_darwin + builds: + - ntfy_darwin_all + wrap_in_directory: true + files: + - LICENSE + - README.md + - client/client.yml +universal_binaries: + - + id: ntfy_darwin_all + replace: true + name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: @@ -51,10 +154,46 @@ changelog: - '^docs:' - '^test:' dockers: - - dockerfile: Dockerfile - ids: - - ntfy + - image_templates: + - &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64" + use: buildx + dockerfile: Dockerfile + goarch: amd64 + build_flag_templates: + - "--platform=linux/amd64" + - image_templates: + - &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8" + use: buildx + dockerfile: Dockerfile-arm + goarch: arm64 + build_flag_templates: + - "--platform=linux/arm64/v8" + - image_templates: + - &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7" + use: buildx + dockerfile: Dockerfile-arm + goarch: arm + goarm: 7 + build_flag_templates: + - "--platform=linux/arm/v7" + - image_templates: + - &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6" + use: buildx + dockerfile: Dockerfile-arm + goarch: arm + goarm: 6 + build_flag_templates: + - "--platform=linux/arm/v6" +docker_manifests: + - name_template: "binwiederhier/ntfy:latest" image_templates: - - "binwiederhier/ntfy:latest" - - "binwiederhier/ntfy:{{ .Tag }}" - - "binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}" + - *amd64_image + - *arm64v8_image + - *armv7_image + - *armv6_image + - name_template: "binwiederhier/ntfy:{{ .Tag }}" + image_templates: + - *amd64_image + - *arm64v8_image + - *armv7_image + - *armv6_image diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..863c0996 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier), +or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/Dockerfile b/Dockerfile index 8e789a7b..45dad05d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,16 @@ FROM alpine -MAINTAINER Philipp C. Heckel +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +RUN apk add --no-cache tzdata COPY ntfy /usr/bin + +EXPOSE 80/tcp ENTRYPOINT ["ntfy"] diff --git a/Dockerfile-arm b/Dockerfile-arm new file mode 100644 index 00000000..755092fd --- /dev/null +++ b/Dockerfile-arm @@ -0,0 +1,18 @@ +FROM alpine + +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +# Alpine does not support adding "tzdata" on ARM anymore, see +# https://github.com/binwiederhier/ntfy/issues/894 + +COPY ntfy /usr/bin + +EXPOSE 80/tcp +ENTRYPOINT ["ntfy"] diff --git a/Dockerfile-build b/Dockerfile-build new file mode 100644 index 00000000..6e96c7d4 --- /dev/null +++ b/Dockerfile-build @@ -0,0 +1,57 @@ +FROM golang:1.20-bullseye as builder + +ARG VERSION=dev +ARG COMMIT=unknown +ARG NODE_MAJOR=18 + +RUN apt-get update && apt-get install -y \ + build-essential ca-certificates curl gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y \ + python3-pip nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ADD Makefile . + +# docs +ADD ./requirements.txt . +RUN 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 +ADD ./web ./web +RUN make web-build + +# cli & server +ADD go.mod go.sum main.go ./ +ADD ./client ./client +ADD ./cmd ./cmd +ADD ./log ./log +ADD ./server ./server +ADD ./user ./user +ADD ./util ./util +RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server + +FROM alpine + +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy + +EXPOSE 80/tcp +ENTRYPOINT ["ntfy"] diff --git a/LICENSE b/LICENSE index 261eeb9e..80877693 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2021 Philipp C. Heckel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSE.GPLv2 b/LICENSE.GPLv2 new file mode 100644 index 00000000..4bf894c5 --- /dev/null +++ b/LICENSE.GPLv2 @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + ntfy + Copyright (C) 2021 Philipp C. Heckel + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + diff --git a/Makefile b/Makefile index 117d3fae..88c22033 100644 --- a/Makefile +++ b/Makefile @@ -1,125 +1,344 @@ -GO=$(shell which go) +MAKEFLAGS := --jobs=1 VERSION := $(shell git describe --tag) +COMMIT := $(shell git rev-parse --short HEAD) .PHONY: help: - @echo "Typical commands:" - @echo " make check - Run all tests, vetting/formatting checks and linters" - @echo " make fmt build-snapshot install - Build latest and install to local system" + @echo "Typical commands (more see below):" + @echo " make build - Build web app, documentation and server/client (sloowwww)" + @echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)" + @echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)" + @echo " make web - Build the web app" + @echo " make docs - Build the documentation" + @echo " make check - Run all tests, vetting/formatting checks and linters" + @echo + @echo "Build everything:" + @echo " make build - Build web app, documentation and server/client" + @echo " make clean - Clean build/dist folders" + @echo + @echo "Build server & client (using GoReleaser, not release version):" + @echo " make cli - Build server & client (all architectures)" + @echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)" + @echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)" + @echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)" + @echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)" + @echo " make cli-windows-amd64 - Build client (Windows, amd64 only)" + @echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)" + @echo + @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-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" + @echo + @echo "Build dev Docker:" + @echo " make docker-dev - Build client & server for current architecture using Docker only" + @echo + @echo "Build web app:" + @echo " make web - Build the web app" + @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-fmt - Run prettier on the web app" + @echo " make web-fmt-check - Run prettier on the web app, but don't change anything" + @echo + @echo "Build documentation:" + @echo " make docs - Build the documentation" + @echo " make docs-deps - Install Python dependencies (pip3 install)" + @echo " make docs-build - Actually build the documentation" @echo @echo "Test/check:" - @echo " make test - Run tests" - @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)" - @echo " make coverage-upload - Upload coverage results to codecov.io" + @echo " make test - Run tests" + @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)" + @echo " make coverage-upload - Upload coverage results to codecov.io" @echo @echo "Lint/format:" - @echo " make fmt - Run 'go fmt'" - @echo " make fmt-check - Run 'go fmt', but don't change anything" - @echo " make vet - Run 'go vet'" - @echo " make lint - Run 'golint'" - @echo " make staticcheck - Run 'staticcheck'" + @echo " make fmt - Run 'go fmt'" + @echo " make fmt-check - Run 'go fmt', but don't change anything" + @echo " make vet - Run 'go vet'" + @echo " make lint - Run 'golint'" + @echo " make staticcheck - Run 'staticcheck'" @echo - @echo "Build:" - @echo " make build - Build" - @echo " make build-snapshot - Build snapshot" - @echo " make build-simple - Build (using go build, without goreleaser)" - @echo " make clean - Clean build folder" - @echo - @echo "Releasing (requires goreleaser):" - @echo " make release - Create a release" - @echo " make release-snapshot - Create a test release" + @echo "Releasing:" + @echo " make release - Create a release" + @echo " make release-snapshot - Create a test release" @echo @echo "Install locally (requires sudo):" - @echo " make install - Copy binary from dist/ to /usr/bin" - @echo " make install-deb - Install .deb from dist/" - @echo " make install-lint - Install golint" + @echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy" + @echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)" + @echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)" + @echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)" + @echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)" +# Building everything + +clean: .PHONY + rm -rf dist build server/docs server/site + +build: web docs cli + +update: web-deps-update cli-deps-update docs-deps-update + docker pull alpine + +docker-dev: + docker build \ + --file ./Dockerfile-build \ + --tag binwiederhier/ntfy:$(VERSION) \ + --tag binwiederhier/ntfy:dev \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + ./ + +# Ubuntu-specific + +build-deps-ubuntu: + sudo apt-get update + sudo apt-get install -y \ + curl \ + gcc-aarch64-linux-gnu \ + gcc-arm-linux-gnueabi \ + jq + which pip3 || sudo apt-get install -y python3-pip + +# Documentation + +docs: docs-deps docs-build + +docs-build: .PHONY + mkdocs build + +docs-deps: .PHONY + pip3 install -r requirements.txt + +docs-deps-update: .PHONY + pip3 install -r requirements.txt --upgrade + + +# Web app + +web: web-deps web-build + +web-build: + cd web \ + && npm run build \ + && mv build/index.html build/app.html \ + && rm -rf ../server/site \ + && mv build ../server/site \ + && rm \ + ../server/site/config.js + +web-deps: + cd web && npm install + # If this fails for .svg files, optimize them with svgo + +web-deps-update: + cd web && npm update + +web-fmt: + cd web && npm run format + +web-fmt-check: + cd web && npm run format:check + +web-lint: + cd web && npm run lint + +# Main server/client build + +cli: cli-deps + goreleaser build --snapshot --clean + +cli-linux-amd64: cli-deps-static-sites + goreleaser build --snapshot --clean --id ntfy_linux_amd64 + +cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 + goreleaser build --snapshot --clean --id ntfy_linux_armv6 + +cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 + goreleaser build --snapshot --clean --id ntfy_linux_armv7 + +cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 + goreleaser build --snapshot --clean --id ntfy_linux_arm64 + +cli-windows-amd64: cli-deps-static-sites + goreleaser build --snapshot --clean --id ntfy_windows_amd64 + +cli-darwin-all: cli-deps-static-sites + goreleaser build --snapshot --clean --id ntfy_darwin_all + +cli-linux-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) manually. + # Use this for development, if you really don't want to install GoReleaser ... + mkdir -p dist/ntfy_linux_server server/docs + CGO_ENABLED=1 go build \ + -o dist/ntfy_linux_server/ntfy \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + +cli-darwin-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) manually. + # Use this for macOS/iOS development, so you have a local server to test with. + mkdir -p dist/ntfy_darwin_server server/docs + CGO_ENABLED=1 go build \ + -o dist/ntfy_darwin_server/ntfy \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-linkmode=external -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 ... + mkdir -p dist/ntfy_client server/docs + CGO_ENABLED=0 go build \ + -o dist/ntfy_client/ntfy \ + -tags noserver \ + -ldflags \ + "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + +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-static-sites: + mkdir -p server/docs server/site + touch server/docs/index.html server/site/app.html + +cli-deps-all: + go install github.com/goreleaser/goreleaser@latest + +cli-deps-gcc-armv6-armv7: + which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } + +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-update: + go get -u + go install honnef.co/go/tools/cmd/staticcheck@latest + go install golang.org/x/lint/golint@latest + go install github.com/goreleaser/goreleaser@latest + +cli-build-results: + cat dist/config.yaml + [ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true + [ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true + [ -f dist/checksums.txt ] && cat dist/checksums.txt || true + find dist -maxdepth 2 -type f \ + \( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \ + -and -not -path 'dist/goreleaserdocker*' \ + -exec sha256sum {} \; + # Test/check targets -check: test fmt-check vet lint staticcheck +check: test web-fmt-check fmt-check vet web-lint lint staticcheck test: .PHONY - $(GO) test ./... + go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + +testv: .PHONY + go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') race: .PHONY - $(GO) test -race ./... + go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') coverage: mkdir -p build/coverage - $(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... - $(GO) tool cover -func build/coverage/coverage.txt + go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go tool cover -func build/coverage/coverage.txt coverage-html: mkdir -p build/coverage - $(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... - $(GO) tool cover -html build/coverage/coverage.txt + go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go tool cover -html build/coverage/coverage.txt coverage-upload: cd build/coverage && (curl -s https://codecov.io/bash | bash) + # Lint/formatting targets -fmt: - $(GO) fmt ./... +fmt: web-fmt + gofmt -s -w . fmt-check: test -z $(shell gofmt -l .) vet: - $(GO) vet ./... + go vet ./... lint: - which golint || $(GO) get -u golang.org/x/lint/golint - $(GO) list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status + which golint || go install golang.org/x/lint/golint@latest + go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status staticcheck: .PHONY rm -rf build/staticcheck - which staticcheck || go get honnef.co/go/tools/cmd/staticcheck + which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest mkdir -p build/staticcheck - ln -s "$(GO)" build/staticcheck/go + ln -s "go" build/staticcheck/go PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... rm -rf build/staticcheck -# Building targets - -build: .PHONY - goreleaser build --rm-dist - -build-snapshot: - goreleaser build --snapshot --rm-dist - -build-simple: clean - mkdir -p dist/ntfy_linux_amd64 - export CGO_ENABLED=1 - $(GO) build \ - -o dist/ntfy_linux_amd64/ntfy \ - -ldflags \ - "-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)" - -clean: .PHONY - rm -rf dist build - # Releasing targets -release: - goreleaser release --rm-dist +release: clean cli-deps release-checks docs web check + goreleaser release --clean -release-snapshot: - goreleaser release --snapshot --skip-publish --rm-dist +release-snapshot: clean cli-deps docs web check + goreleaser release --snapshot --skip-publish --clean + +release-checks: + $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) + if ! grep -q $(LATEST_TAG) docs/install.md; then\ + echo "ERROR: Must update docs/install.md with latest tag first.";\ + exit 1;\ + fi + if ! grep -q $(LATEST_TAG) docs/releases.md; then\ + echo "ERROR: Must update docs/releases.md with latest tag first.";\ + exit 1;\ + fi + if [ -n "$(shell git status -s)" ]; then\ + echo "ERROR: Git repository is in an unclean state.";\ + exit 1;\ + fi # Installing targets -install: - sudo rm -f /usr/bin/ntfy - sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy +install-linux-amd64: remove-binary + sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy -install-deb: +install-linux-armv6: remove-binary + sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy + +install-linux-armv7: remove-binary + sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy + +install-linux-arm64: remove-binary + sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy + +remove-binary: + sudo rm -f /usr/bin/ntfy + +install-linux-amd64-deb: purge-package + sudo dpkg -i dist/ntfy_*_linux_amd64.deb + +install-linux-armv6-deb: purge-package + sudo dpkg -i dist/ntfy_*_linux_armv6.deb + +install-linux-armv7-deb: purge-package + sudo dpkg -i dist/ntfy_*_linux_armv7.deb + +install-linux-arm64-deb: purge-package + sudo dpkg -i dist/ntfy_*_linux_arm64.deb + +purge-package: sudo systemctl stop ntfy || true sudo apt-get purge ntfy || true - sudo dpkg -i dist/*.deb diff --git a/README.md b/README.md index 3a58eed4..b3d0c55e 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,9 @@ -![ntfy](server/static/img/ntfy.png) +# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST -# ntfy - simple HTTP-based pub-sub +**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) +notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, +**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do +so since ntfy is open source. -**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. -It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**. -It's also open source (as you can plainly see) if you want to run your own. -I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy) -too. - -## Usage - -### Publishing messages - -Publishing messages can be done via PUT or POST using. Topics are created on the fly by subscribing or publishing to them. -Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. - -Here's an example showing how to publish a message using `curl`: - -``` -curl -d "long process is done" ntfy.sh/mytopic -``` - -Here's an example in JS with `fetch()` (see [full example](examples)): - -``` -fetch('https://ntfy.sh/mytopic', { - method: 'POST', // PUT works too - body: 'Hello from the other side.' -}) -``` - -### Subscribe to a topic -You can create and subscribe to a topic either in this web UI, or in your own app by subscribing to an -[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), a JSON feed, or raw feed. - -#### Subscribe via web -If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic -will show up as **desktop notification**. - -You can try this easily on **[ntfy.sh](https://ntfy.sh)**. - -#### Subscribe via phone -You can use the [Ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive -notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android). - -#### Subscribe via your app, or via the CLI -Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JS, you can consume -notifications like this (see [full example](examples)): - -```javascript -const eventSource = new EventSource('https://ntfy.sh/mytopic/sse');
-eventSource.onmessage = (e) => {
- // Do something with e.data
-}; -``` - -You can also use the same `/sse` endpoint via `curl` or any other HTTP library: - -``` -$ curl -s ntfy.sh/mytopic/sse -event: open -data: {"id":"weSj9RtNkj","time":1635528898,"event":"open","topic":"mytopic"} - -data: {"id":"p0M5y6gcCY","time":1635528909,"event":"message","topic":"mytopic","message":"Hi!"} - -event: keepalive -data: {"id":"VNxNIg5fpt","time":1635528928,"event":"keepalive","topic":"test"} -``` - -To consume JSON instead, use the `/json` endpoint, which prints one message per line: - -``` -$ curl -s ntfy.sh/mytopic/json -{"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"} -{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"} -{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"} -``` - -Or use the `/raw` endpoint if you need something super simple (empty lines are keepalive messages): - -``` -$ curl -s ntfy.sh/mytopic/raw - -This is a notification -``` - -#### Message buffering and polling -Messages are buffered in memory for a few hours to account for network interruptions of subscribers. -You can read back what you missed by using the `since=...` query parameter. It takes either a -duration (e.g. `10m` or `30s`) or a Unix timestamp (e.g. `1635528757`): - -``` -$ curl -s "ntfy.sh/mytopic/json?since=10m" -# Same output as above, but includes messages from up to 10 minutes ago -``` - -You can also just poll for messages if you don't like the long-standing connection using the `poll=1` -query parameter. The connection will end after all available messages have been read. This parameter has to be -combined with `since=`. - -``` -$ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m" -# Returns messages from up to 10 minutes ago and ends the connection -``` - -## Examples -There are a few usage examples in the [examples](examples) directory. I'm sure there are tons of other ways to use it. - -## Installation -Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and -deb/rpm packages. - -1. Install ntfy using one of the methods described below -2. Then (optionally) edit `/etc/ntfy/config.yml` -3. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm). - -### Binaries and packages -**Debian/Ubuntu** (*from a repository*)**:** -```bash -curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add - -sudo apt install apt-transport-https -sudo sh -c "echo 'deb [arch=amd64] https://archive.heckel.io/apt debian main' > /etc/apt/sources.list.d/archive.heckel.io.list" -sudo apt update -sudo apt install ntfy -``` - -**Debian/Ubuntu** (*manual install*)**:** -```bash -sudo apt install tmux -wget https://github.com/binwiederhier/ntfy/releases/download/v1.2.0/ntfy_1.2.0_amd64.deb -dpkg -i ntfy_1.2.0_amd64.deb -``` - -**Fedora/RHEL/CentOS:** -```bash -rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.2.0/ntfy_1.2.0_amd64.rpm -``` - -**Docker:** -```bash -docker run --rm -it binwiederhier/ntfy -``` - -**Go:** -```bash -go get -u heckel.io/ntfy -``` - -**Manual install** (*any x86_64-based Linux*)**:** -```bash -wget https://github.com/binwiederhier/ntfy/releases/download/v1.2.0/ntfy_1.2.0_linux_x86_64.tar.gz -sudo tar -C /usr/bin -zxf ntfy_1.2.0_linux_x86_64.tar.gz ntfy -./ntfy -``` - -## Building -Building ntfy is simple. Here's how you do it: - -``` -make build-simple -# Builds to dist/ntfy_linux_amd64/ntfy -``` - -To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that installed, you can run `make build` or -`make build-snapshot`. - -## TODO -- add HTTPS -- make limits configurable - -## Contributing -I welcome any and all contributions. Just create a PR or an issue. - -## License -Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE). - -Third party libraries and resources: -* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI -* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound -* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI -* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases -* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache -* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages +### This is a fork of [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..45573756 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security Policy + +## Supported Versions + +As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date. + +## 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`). diff --git a/client/client.go b/client/client.go new file mode 100644 index 00000000..191df260 --- /dev/null +++ b/client/client.go @@ -0,0 +1,292 @@ +// Package client provides a ntfy client to publish and subscribe to topics +package client + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "io" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +const ( + // MessageEvent identifies a message event + MessageEvent = "message" +) + +const ( + maxResponseBytes = 4096 +) + +var ( + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go +) + +// Client is the ntfy client that can be used to publish and subscribe to ntfy topics +type Client struct { + Messages chan *Message + config *Config + subscriptions map[string]*subscription + mu sync.Mutex +} + +// Message is a struct that represents a ntfy message +type Message struct { // TODO combine with server.message + ID string + Event string + Time int64 + Topic string + Message string + Title string + Priority int + Tags []string + Click string + Icon string + Attachment *Attachment + + // Additional fields + TopicURL string + SubscriptionID string + Raw string +} + +// Attachment represents a message attachment +type Attachment struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` + Expires int64 `json:"expires,omitempty"` + URL string `json:"url"` + Owner string `json:"-"` // IP address of uploader, used for rate limiting +} + +type subscription struct { + ID string + topicURL string + cancel context.CancelFunc +} + +// New creates a new Client using a given Config +func New(config *Config) *Client { + return &Client{ + Messages: make(chan *Message, 50), // Allow reading a few messages + config: config, + subscriptions: make(map[string]*subscription), + } +} + +// Publish sends a message to a specific topic, optionally using options. +// See PublishReader for details. +func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) { + return c.PublishReader(topic, strings.NewReader(message), options...) +} + +// PublishReader sends a message to a specific topic, optionally using options. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +// +// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, +// WithNoFirebase, and the generic WithHeader. +func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", topicURL, body) + if err != nil { + return nil, err + } + for _, option := range options { + if err := option(req); err != nil { + return nil, err + } + } + log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New(strings.TrimSpace(string(b))) + } + m, err := toMessage(string(b), topicURL, "") + if err != nil { + return nil, err + } + return m, nil +} + +// Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for +// messages and does not subscribe to messages that arrive after this call. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +// +// By default, all messages will be returned, but you can change this behavior using a SubscribeOption. +// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. +func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return nil, err + } + ctx := context.Background() + messages := make([]*Message, 0) + msgChan := make(chan *Message) + errChan := make(chan error) + log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) + options = append(options, WithPoll()) + go func() { + err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...) + close(msgChan) + errChan <- err + }() + for m := range msgChan { + messages = append(messages, m) + } + return messages, <-errChan +} + +// Subscribe subscribes to a topic to listen for newly incoming messages. The method starts a connection in the +// background and returns new messages via the Messages channel. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +// +// By default, only new messages will be returned, but you can change this behavior using a SubscribeOption. +// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. +// +// The method returns a unique subscriptionID that can be used in Unsubscribe. +// +// Example: +// +// c := client.New(client.NewConfig()) +// subscriptionID, _ := c.Subscribe("mytopic") +// for m := range c.Messages { +// fmt.Printf("New message: %s", m.Message) +// } +func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return "", err + } + c.mu.Lock() + defer c.mu.Unlock() + subscriptionID := util.RandomString(10) + log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) + ctx, cancel := context.WithCancel(context.Background()) + c.subscriptions[subscriptionID] = &subscription{ + ID: subscriptionID, + topicURL: topicURL, + cancel: cancel, + } + go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) + return subscriptionID, nil +} + +// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique +// subscriptionID returned in Subscribe. +func (c *Client) Unsubscribe(subscriptionID string) { + c.mu.Lock() + defer c.mu.Unlock() + sub, ok := c.subscriptions[subscriptionID] + if !ok { + return + } + delete(c.subscriptions, subscriptionID) + sub.cancel() +} + +func (c *Client) expandTopicURL(topic string) (string, error) { + if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { + return topic, nil + } else if strings.Contains(topic, "/") { + return fmt.Sprintf("https://%s", topic), nil + } + if !topicRegex.MatchString(topic) { + return "", fmt.Errorf("invalid topic name: %s", topic) + } + return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil +} + +func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { + for { + // TODO The retry logic is crude and may lose messages. It should record the last message like the + // Android client, use since=, and do incremental backoff too + if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil { + log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error()) + } + select { + case <-ctx.Done(): + log.Info("%s Connection exited", util.ShortTopicURL(topicURL)) + return + case <-time.After(10 * time.Second): // TODO Add incremental backoff + } + } +} + +func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error { + streamURL := fmt.Sprintf("%s/json", topicURL) + log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) + if err != nil { + return err + } + for _, option := range options { + if err := option(req); err != nil { + return err + } + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return err + } + return errors.New(strings.TrimSpace(string(b))) + } + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + messageJSON := scanner.Text() + m, err := toMessage(messageJSON, topicURL, subscriptionID) + if err != nil { + return err + } + log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON) + if m.Event == MessageEvent { + msgChan <- m + } + } + return nil +} + +func toMessage(s, topicURL, subscriptionID string) (*Message, error) { + var m *Message + if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { + return nil, err + } + m.TopicURL = topicURL + m.SubscriptionID = subscriptionID + m.Raw = s + return m, nil +} diff --git a/client/client.yml b/client/client.yml new file mode 100644 index 00000000..ebf4c281 --- /dev/null +++ b/client/client.yml @@ -0,0 +1,57 @@ +# ntfy client config file + +# Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands. +# If you self-host a ntfy server, you'll likely want to change this. +# +# default-host: https://ntfy.sh + +# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided. +# You can set a default token to use or a default user:password combination, but not both. For an empty password, +# use empty double-quotes (""). +# +# To override the default user:password combination or default token for a particular subscription (e.g., to send +# no Authorization header), set the user:pass/token for the subscription to empty double-quotes (""). + +# default-token: + +# default-user: +# default-password: + +# Default command will execute after "ntfy subscribe" receives a message if no command is provided in subscription below +# default-command: + +# Subscriptions to topics and their actions. This option is primarily used by the systemd service, +# or if you cann "ntfy subscribe --from-config" directly. +# +# Example: +# subscribe: +# - topic: mytopic +# command: /usr/local/bin/mytopic-triggered.sh +# - topic: myserver.com/anothertopic +# command: 'echo "$message"' +# if: +# priority: high,urgent +# - topic: secret +# command: 'notify-send "$m"' +# user: phill +# password: mypass +# - topic: token_topic +# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +# +# Variables: +# Variable Aliases Description +# --------------- --------------------- ----------------------------------- +# $NTFY_ID $id Unique message ID +# $NTFY_TIME $time Unix timestamp of the message delivery +# $NTFY_TOPIC $topic Topic name +# $NTFY_MESSAGE $message, $m Message body +# $NTFY_TITLE $title, $t Message title +# $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) +# $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) +# $NTFY_RAW $raw Raw JSON message +# +# Filters ('if:'): +# You can filter 'message', 'title', 'priority' (comma-separated list, logical OR) +# and 'tags' (comma-separated list, logical AND). See https://ntfy.sh/docs/subscribe/api/#filter-messages. +# +# subscribe: diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 00000000..7ab39db6 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,117 @@ +package client_test + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/test" + "github.com/stretchr/testify/require" + "os" + "testing" + "time" +) + +func TestMain(m *testing.M) { + log.SetLevel(log.ErrorLevel) + os.Exit(m.Run()) +} + +func TestClient_Publish_Subscribe(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + c := client.New(newTestConfig(port)) + + subscriptionID, _ := c.Subscribe("mytopic") + time.Sleep(time.Second) + + msg, err := c.Publish("mytopic", "some message") + require.Nil(t, err) + require.Equal(t, "some message", msg.Message) + + msg, err = c.Publish("mytopic", "some other message", + client.WithTitle("some title"), + client.WithPriority("high"), + client.WithTags([]string{"tag1", "tag 2"})) + require.Nil(t, err) + require.Equal(t, "some other message", msg.Message) + require.Equal(t, "some title", msg.Title) + require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags) + require.Equal(t, 4, msg.Priority) + + msg, err = c.Publish("mytopic", "some delayed message", + client.WithDelay("25 hours")) + require.Nil(t, err) + require.Equal(t, "some delayed message", msg.Message) + require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time) + + time.Sleep(200 * time.Millisecond) + + msg = nextMessage(c) + require.NotNil(t, msg) + require.Equal(t, "some message", msg.Message) + + msg = nextMessage(c) + require.NotNil(t, msg) + require.Equal(t, "some other message", msg.Message) + require.Equal(t, "some title", msg.Title) + require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags) + require.Equal(t, 4, msg.Priority) + + msg = nextMessage(c) + require.Nil(t, msg) + + c.Unsubscribe(subscriptionID) + time.Sleep(200 * time.Millisecond) + + msg, err = c.Publish("mytopic", "a message that won't be received") + require.Nil(t, err) + require.Equal(t, "a message that won't be received", msg.Message) + + msg = nextMessage(c) + require.Nil(t, msg) +} + +func TestClient_Publish_Poll(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + c := client.New(newTestConfig(port)) + + msg, err := c.Publish("mytopic", "some message", client.WithNoFirebase(), client.WithTagsList("tag1,tag2")) + require.Nil(t, err) + require.Equal(t, "some message", msg.Message) + require.Equal(t, []string{"tag1", "tag2"}, msg.Tags) + + msg, err = c.Publish("mytopic", "this won't be cached", client.WithNoCache()) + require.Nil(t, err) + require.Equal(t, "this won't be cached", msg.Message) + + msg, err = c.Publish("mytopic", "some delayed message", client.WithDelay("20 min")) + require.Nil(t, err) + require.Equal(t, "some delayed message", msg.Message) + + messages, err := c.Poll("mytopic") + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "some message", messages[0].Message) + + messages, err = c.Poll("mytopic", client.WithScheduled()) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + require.Equal(t, "some message", messages[0].Message) + require.Equal(t, "some delayed message", messages[1].Message) +} + +func newTestConfig(port int) *client.Config { + c := client.NewConfig() + c.DefaultHost = fmt.Sprintf("http://127.0.0.1:%d", port) + return c +} + +func nextMessage(c *client.Client) *client.Message { + select { + case m := <-c.Messages: + return m + default: + return nil + } +} diff --git a/client/config.go b/client/config.go new file mode 100644 index 00000000..bc46ab89 --- /dev/null +++ b/client/config.go @@ -0,0 +1,56 @@ +package client + +import ( + "gopkg.in/yaml.v2" + "os" +) + +const ( + // DefaultBaseURL is the base URL used to expand short topic names + DefaultBaseURL = "https://ntfy.sh" +) + +// Config is the config struct for a Client +type Config struct { + DefaultHost string `yaml:"default-host"` + DefaultUser string `yaml:"default-user"` + DefaultPassword *string `yaml:"default-password"` + DefaultToken string `yaml:"default-token"` + DefaultCommand string `yaml:"default-command"` + Subscribe []Subscribe `yaml:"subscribe"` +} + +// Subscribe is the struct for a Subscription within Config +type Subscribe struct { + Topic string `yaml:"topic"` + User *string `yaml:"user"` + Password *string `yaml:"password"` + Token *string `yaml:"token"` + Command string `yaml:"command"` + If map[string]string `yaml:"if"` +} + +// NewConfig creates a new Config struct for a Client +func NewConfig() *Config { + return &Config{ + DefaultHost: DefaultBaseURL, + DefaultUser: "", + DefaultPassword: nil, + DefaultToken: "", + DefaultCommand: "", + Subscribe: nil, + } +} + +// LoadConfig loads the Client config from a yaml file +func LoadConfig(filename string) (*Config, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + c := NewConfig() + if err := yaml.Unmarshal(b, c); err != nil { + return nil, err + } + return c, nil +} diff --git a/client/config_test.go b/client/config_test.go new file mode 100644 index 00000000..f4c86bfb --- /dev/null +++ b/client/config_test.go @@ -0,0 +1,140 @@ +package client_test + +import ( + "git.zio.sh/astra/ntfy/v2/client" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func TestConfig_Load(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +default-password: mypass +default-command: 'echo "Got the message: $message"' +subscribe: + - topic: no-command-with-auth + user: phil + password: mypass + - topic: echo-this + command: 'echo "Message received: $message"' + - topic: alerts + command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m" + if: + priority: high,urgent + - topic: defaults +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Equal(t, "mypass", *conf.DefaultPassword) + require.Equal(t, `echo "Got the message: $message"`, conf.DefaultCommand) + require.Equal(t, 4, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Equal(t, "mypass", *conf.Subscribe[0].Password) + require.Equal(t, "echo-this", conf.Subscribe[1].Topic) + require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command) + require.Equal(t, "alerts", conf.Subscribe[2].Topic) + require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command) + require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"]) + require.Equal(t, "defaults", conf.Subscribe[3].Topic) +} + +func TestConfig_EmptyPassword(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +default-password: "" +subscribe: + - topic: no-command-with-auth + user: phil + password: "" +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Equal(t, "", *conf.DefaultPassword) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Equal(t, "", *conf.Subscribe[0].Password) +} + +func TestConfig_NullPassword(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +default-password: ~ +subscribe: + - topic: no-command-with-auth + user: phil + password: ~ +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) +} + +func TestConfig_NoPassword(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-user: philipp +subscribe: + - topic: no-command-with-auth + user: phil +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "philipp", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic) + require.Equal(t, "", conf.Subscribe[0].Command) + require.Equal(t, "phil", *conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) +} + +func TestConfig_DefaultToken(t *testing.T) { + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(` +default-host: http://localhost +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic +`), 0600)) + + conf, err := client.LoadConfig(filename) + require.Nil(t, err) + require.Equal(t, "http://localhost", conf.DefaultHost) + require.Equal(t, "", conf.DefaultUser) + require.Nil(t, conf.DefaultPassword) + require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken) + require.Equal(t, 1, len(conf.Subscribe)) + require.Equal(t, "mytopic", conf.Subscribe[0].Topic) + require.Nil(t, conf.Subscribe[0].User) + require.Nil(t, conf.Subscribe[0].Password) + require.Nil(t, conf.Subscribe[0].Token) +} diff --git a/client/ntfy-client.service b/client/ntfy-client.service new file mode 100644 index 00000000..27925d77 --- /dev/null +++ b/client/ntfy-client.service @@ -0,0 +1,12 @@ +[Unit] +Description=ntfy client +After=network.target + +[Service] +User=ntfy +Group=ntfy +ExecStart=/usr/bin/ntfy subscribe --config /etc/ntfy/client.yml --from-config +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/client/options.go b/client/options.go new file mode 100644 index 00000000..1bf48faf --- /dev/null +++ b/client/options.go @@ -0,0 +1,204 @@ +package client + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "net/http" + "strings" + "time" +) + +// RequestOption is a generic request option that can be added to Client calls +type RequestOption = func(r *http.Request) error + +// PublishOption is an option that can be passed to the Client.Publish call +type PublishOption = RequestOption + +// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call +type SubscribeOption = RequestOption + +// WithMessage sets the notification message. This is an alternative way to passing the message body. +func WithMessage(message string) PublishOption { + return WithHeader("X-Message", message) +} + +// WithTitle adds a title to a message +func WithTitle(title string) PublishOption { + return WithHeader("X-Title", title) +} + +// WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max), +// or the corresponding names (see util.ParsePriority). +func WithPriority(priority string) PublishOption { + return WithHeader("X-Priority", priority) +} + +// WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list +// of tags. To use a slice, use WithTags instead +func WithTagsList(tags string) PublishOption { + return WithHeader("X-Tags", tags) +} + +// WithTags adds a list of a tags to a message +func WithTags(tags []string) PublishOption { + return WithTagsList(strings.Join(tags, ",")) +} + +// WithDelay instructs the server to send the message at a later date. The delay parameter can be a +// Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery +// for details. +func WithDelay(delay string) PublishOption { + return WithHeader("X-Delay", delay) +} + +// WithClick makes the notification action open the given URL as opposed to entering the detail view +func WithClick(url string) PublishOption { + return WithHeader("X-Click", url) +} + +// WithIcon makes the notification use the given URL as its icon +func WithIcon(icon string) PublishOption { + return WithHeader("X-Icon", icon) +} + +// WithActions adds custom user actions to the notification. The value can be either a JSON array or the +// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details. +func WithActions(value string) PublishOption { + return WithHeader("X-Actions", value) +} + +// WithAttach sets a URL that will be used by the client to download an attachment +func WithAttach(attach string) PublishOption { + return WithHeader("X-Attach", attach) +} + +// WithMarkdown instructs the server to interpret the message body as Markdown +func WithMarkdown() PublishOption { + return WithHeader("X-Markdown", "yes") +} + +// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment +func WithFilename(filename string) PublishOption { + return WithHeader("X-Filename", filename) +} + +// WithEmail instructs the server to also send the message to the given e-mail address +func WithEmail(email string) PublishOption { + return WithHeader("X-Email", email) +} + +// WithBasicAuth adds the Authorization header for basic auth to the request +func WithBasicAuth(user, pass string) PublishOption { + return WithHeader("Authorization", util.BasicAuth(user, pass)) +} + +// WithBearerAuth adds the Authorization header for Bearer auth to the request +func WithBearerAuth(token string) PublishOption { + return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token)) +} + +// WithEmptyAuth clears the Authorization header +func WithEmptyAuth() PublishOption { + return RemoveHeader("Authorization") +} + +// WithNoCache instructs the server not to cache the message server-side +func WithNoCache() PublishOption { + return WithHeader("X-Cache", "no") +} + +// WithNoFirebase instructs the server not to forward the message to Firebase +func WithNoFirebase() PublishOption { + return WithHeader("X-Firebase", "no") +} + +// WithSince limits the number of messages returned from the server. The parameter since can be a Unix +// timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word "all" (see WithSinceAll). +func WithSince(since string) SubscribeOption { + return WithQueryParam("since", since) +} + +// WithSinceAll instructs the server to return all messages for the given topic from the server +func WithSinceAll() SubscribeOption { + return WithSince("all") +} + +// WithSinceDuration instructs the server to return all messages since the given duration ago +func WithSinceDuration(since time.Duration) SubscribeOption { + return WithSinceUnixTime(time.Now().Add(-1 * since).Unix()) +} + +// WithSinceUnixTime instructs the server to return only messages newer or equal to the given timestamp +func WithSinceUnixTime(since int64) SubscribeOption { + return WithSince(fmt.Sprintf("%d", since)) +} + +// WithPoll instructs the server to close the connection after messages have been returned. Don't use this option +// directly. Use Client.Poll instead. +func WithPoll() SubscribeOption { + return WithQueryParam("poll", "1") +} + +// WithScheduled instructs the server to also return messages that have not been sent yet, i.e. delayed/scheduled +// messages (see WithDelay). The messages will have a future date. +func WithScheduled() SubscribeOption { + return WithQueryParam("scheduled", "1") +} + +// WithFilter is a generic subscribe option meant to be used to filter for certain messages only +func WithFilter(param, value string) SubscribeOption { + return WithQueryParam(param, value) +} + +// WithMessageFilter instructs the server to only return messages that match the exact message +func WithMessageFilter(message string) SubscribeOption { + return WithQueryParam("message", message) +} + +// WithTitleFilter instructs the server to only return messages with a title that match the exact string +func WithTitleFilter(title string) SubscribeOption { + return WithQueryParam("title", title) +} + +// WithPriorityFilter instructs the server to only return messages with the matching priority. Not that messages +// without priority also implicitly match priority 3. +func WithPriorityFilter(priority int) SubscribeOption { + return WithQueryParam("priority", fmt.Sprintf("%d", priority)) +} + +// WithTagsFilter instructs the server to only return messages that contain all of the given tags +func WithTagsFilter(tags []string) SubscribeOption { + return WithQueryParam("tags", strings.Join(tags, ",")) +} + +// WithHeader is a generic option to add headers to a request +func WithHeader(header, value string) RequestOption { + return func(r *http.Request) error { + if value != "" { + r.Header.Set(header, value) + } + return nil + } +} + +// WithQueryParam is a generic option to add query parameters to a request +func WithQueryParam(param, value string) RequestOption { + return func(r *http.Request) error { + if value != "" { + q := r.URL.Query() + q.Add(param, value) + r.URL.RawQuery = q.Encode() + } + return nil + } +} + +// RemoveHeader is a generic option to remove a header from a request +func RemoveHeader(header string) RequestOption { + return func(r *http.Request) error { + if header != "" { + delete(r.Header, header) + } + return nil + } +} diff --git a/cmd/access.go b/cmd/access.go new file mode 100644 index 00000000..0dc4719e --- /dev/null +++ b/cmd/access.go @@ -0,0 +1,228 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" +) + +func init() { + commands = append(commands, cmdAccess) +} + +const ( + userEveryone = "everyone" +) + +var flagsAccess = append( + append([]cli.Flag{}, flagsUser...), + &cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, +) + +var cmdAccess = &cli.Command{ + Name: "access", + Usage: "Grant/revoke access to a topic, or show access", + UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]", + Flags: flagsAccess, + Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc), + Action: execUserAccess, + Category: categoryServer, + Description: `Manage the access control list for the ntfy server. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. Please also refer +to the related command 'ntfy user'. + +The command allows you to show the access control list, as well as change it, depending on how +it is called. + +Usage: + ntfy access # Shows access control list (alias: 'ntfy user list') + ntfy access USERNAME # Shows access control entries for USERNAME + ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC + +Arguments: + USERNAME an existing user, as created with 'ntfy user add', or "everyone"/"*" + to define access rules for anonymous/unauthenticated clients + TOPIC name of a topic with optional wildcards, e.g. "mytopic*" + PERMISSION one of the following: + - read-write (alias: rw) + - read-only (aliases: read, ro) + - write-only (aliases: write, wo) + - deny (alias: none) + +Examples: + ntfy access # Shows access control list (alias: 'ntfy user list') + ntfy access phil # Shows access for user phil + ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil + ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic + ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..." + ntfy access --reset # Reset entire access control list + ntfy access --reset phil # Reset all access for user phil + ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic +`, +} + +func execUserAccess(c *cli.Context) error { + if c.NArg() > 3 { + return errors.New("too many arguments, please check 'ntfy access --help' for usage details") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + username := c.Args().Get(0) + if username == userEveryone { + username = user.Everyone + } + topic := c.Args().Get(1) + perms := c.Args().Get(2) + reset := c.Bool("reset") + if reset { + if perms != "" { + return errors.New("too many arguments, please check 'ntfy access --help' for usage details") + } + return resetAccess(c, manager, username, topic) + } else if perms == "" { + if topic != "" { + return errors.New("invalid syntax, please check 'ntfy access --help' for usage details") + } + return showAccess(c, manager, username) + } + return changeAccess(c, manager, username, topic, perms) +} + +func changeAccess(c *cli.Context, manager *user.Manager, username string, topic string, perms string) error { + if !util.Contains([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) { + return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)") + } + permission, err := user.ParsePermission(perms) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if u.Role == user.RoleAdmin { + return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) + } + if err := manager.AllowAccess(username, topic, permission); err != nil { + return err + } + if permission.IsReadWrite() { + fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic) + } else if permission.IsRead() { + fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic) + } else if permission.IsWrite() { + fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic) + } else { + fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic) + } + return showUserAccess(c, manager, username) +} + +func resetAccess(c *cli.Context, manager *user.Manager, username, topic string) error { + if username == "" { + return resetAllAccess(c, manager) + } else if topic == "" { + return resetUserAccess(c, manager, username) + } + return resetUserTopicAccess(c, manager, username, topic) +} + +func resetAllAccess(c *cli.Context, manager *user.Manager) error { + if err := manager.ResetAccess("", ""); err != nil { + return err + } + fmt.Fprintln(c.App.ErrWriter, "reset access for all users") + return nil +} + +func resetUserAccess(c *cli.Context, manager *user.Manager, username string) error { + if err := manager.ResetAccess(username, ""); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username) + return showUserAccess(c, manager, username) +} + +func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string, topic string) error { + if err := manager.ResetAccess(username, topic); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic) + return showUserAccess(c, manager, username) +} + +func showAccess(c *cli.Context, manager *user.Manager, username string) error { + if username == "" { + return showAllAccess(c, manager) + } + return showUserAccess(c, manager, username) +} + +func showAllAccess(c *cli.Context, manager *user.Manager) error { + users, err := manager.Users() + if err != nil { + return err + } + return showUsers(c, manager, users) +} + +func showUserAccess(c *cli.Context, manager *user.Manager, username string) error { + users, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + return showUsers(c, manager, []*user.User{users}) +} + +func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error { + for _, u := range users { + grants, err := manager.Grants(u.Name) + if err != nil { + return err + } + tier := "none" + if u.Tier != nil { + tier = u.Tier.Name + } + fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier) + if u.Role == user.RoleAdmin { + fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") + } else if len(grants) > 0 { + for _, grant := range grants { + if grant.Allow.IsReadWrite() { + fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) + } else if grant.Allow.IsRead() { + fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern) + } else if grant.Allow.IsWrite() { + fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern) + } else { + fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern) + } + } + } else { + fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n") + } + if u.Name == user.Everyone { + access := manager.DefaultAccess() + if access.IsReadWrite() { + fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)") + } else if access.IsRead() { + fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)") + } else if access.IsWrite() { + fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)") + } else { + fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)") + } + } + } + return nil +} diff --git a/cmd/access_test.go b/cmd/access_test.go new file mode 100644 index 00000000..d872021a --- /dev/null +++ b/cmd/access_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "testing" +) + +func TestCLI_Access_Show(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, _, _, stderr := newTestApp() + require.Nil(t, runAccessCommand(app, conf)) + require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") +} + +func TestCLI_Access_Grant_And_Publish(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass") + require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) + require.Nil(t, runUserCommand(app, conf, "add", "ben")) + require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw")) + require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read")) + require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read")) + + app, _, _, stderr := newTestApp() + require.Nil(t, runAccessCommand(app, conf)) + expected := `user phil (role: admin, tier: none) +- read-write access to all topics (admin role) +user ben (role: user, tier: none) +- read-write access to topic announcements +- read-only access to topic sometopic +user * (role: anonymous, tier: none) +- read-only access to topic announcements +- no access to any (other) topics (server config) +` + require.Equal(t, expected, stderr.String()) + + // See if access permissions match + app, _, _, _ = newTestApp() + require.Error(t, app.Run([]string{ + "ntfy", + "publish", + fmt.Sprintf("http://127.0.0.1:%d/announcements", port), + })) + require.Nil(t, app.Run([]string{ + "ntfy", + "publish", + "-u", "ben:benpass", + fmt.Sprintf("http://127.0.0.1:%d/announcements", port), + })) + require.Nil(t, app.Run([]string{ + "ntfy", + "publish", + "-u", "phil:philpass", + fmt.Sprintf("http://127.0.0.1:%d/announcements", port), + })) + require.Nil(t, app.Run([]string{ + "ntfy", + "subscribe", + "--poll", + fmt.Sprintf("http://127.0.0.1:%d/announcements", port), + })) + require.Error(t, app.Run([]string{ + "ntfy", + "subscribe", + "--poll", + fmt.Sprintf("http://127.0.0.1:%d/something-else", port), + })) +} + +func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error { + userArgs := []string{ + "ntfy", + "--log-level=ERROR", + "access", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file + "--auth-file=" + conf.AuthFile, + "--auth-default-access=" + conf.AuthDefault.String(), + } + return app.Run(append(userArgs, args...)) +} diff --git a/cmd/app.go b/cmd/app.go index b7d3722e..27e876b6 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -2,98 +2,89 @@ package cmd import ( - "errors" "fmt" + "git.zio.sh/astra/ntfy/v2/log" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/config" - "heckel.io/ntfy/server" - "heckel.io/ntfy/util" - "log" "os" - "time" + "regexp" +) + +const ( + categoryClient = "Client commands" + categoryServer = "Server commands" +) + +var commands = make([]*cli.Command, 0) + +var flagsDefault = []cli.Flag{ + &cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"}, + &cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"}, + &cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}), +} + +var ( + logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`) ) // New creates a new CLI application func New() *cli.App { - flags := []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/config.yml", DefaultText: "/etc/ntfy/config.yml", Usage: "config file"}, - altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: config.DefaultListenHTTP, Usage: "ip:port used to as listen address"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: config.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "default interval of keepalive messages"}), - altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: config.DefaultManagerInterval, Usage: "default interval of for message pruning and stats printing"}), - } return &cli.App{ Name: "ntfy", Usage: "Simple pub-sub notification service", UsageText: "ntfy [OPTION..]", - HideHelp: true, HideVersion: true, - EnableBashCompletion: true, UseShortOptionHandling: true, Reader: os.Stdin, Writer: os.Stdout, ErrWriter: os.Stderr, - Action: execRun, - Before: initConfigFileInputSource("config", flags), - Flags: flags, + Commands: commands, + Flags: flagsDefault, + Before: initLogFunc, } } -func execRun(c *cli.Context) error { - // Read all the options - listenHTTP := c.String("listen-http") - firebaseKeyFile := c.String("firebase-key-file") - cacheFile := c.String("cache-file") - cacheDuration := c.Duration("cache-duration") - keepaliveInterval := c.Duration("keepalive-interval") - managerInterval := c.Duration("manager-interval") - - // Check values - if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { - return errors.New("if set, FCM key file must exist") - } else if keepaliveInterval < 5*time.Second { - return errors.New("keepalive interval cannot be lower than five seconds") - } else if managerInterval < 5*time.Second { - return errors.New("manager interval cannot be lower than five seconds") - } else if cacheDuration < managerInterval { - return errors.New("cache duration cannot be lower than manager interval") +func initLogFunc(c *cli.Context) error { + log.SetLevel(log.ToLevel(c.String("log-level"))) + log.SetFormat(log.ToFormat(c.String("log-format"))) + if c.Bool("trace") { + log.SetLevel(log.TraceLevel) + } else if c.Bool("debug") { + log.SetLevel(log.DebugLevel) } - - // Run server - conf := config.New(listenHTTP) - conf.FirebaseKeyFile = firebaseKeyFile - conf.CacheFile = cacheFile - conf.CacheDuration = cacheDuration - conf.KeepaliveInterval = keepaliveInterval - conf.ManagerInterval = managerInterval - s, err := server.New(conf) - if err != nil { - log.Fatalln(err) + if c.Bool("no-log-dates") { + log.DisableDates() } - if err := s.Run(); err != nil { - log.Fatalln(err) + if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil { + return err } - log.Printf("Exiting.") - return nil -} - -// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks -// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. -func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc { - return func(context *cli.Context) error { - configFile := context.String(configFlag) - if context.IsSet(configFlag) && !util.FileExists(configFile) { - return fmt.Errorf("config file %s does not exist", configFile) - } else if !context.IsSet(configFlag) && !util.FileExists(configFile) { - return nil - } - inputSource, err := altsrc.NewYamlSourceFromFile(configFile) + logFile := c.String("log-file") + if logFile != "" { + w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return err } - return altsrc.ApplyInputSourceValues(context, inputSource, flags) + log.SetOutput(w) } + return nil +} + +func applyLogLevelOverrides(rawOverrides []string) error { + for _, override := range rawOverrides { + m := logLevelOverrideRegex.FindStringSubmatch(override) + if len(m) == 4 { + field, value, level := m[1], m[2], m[3] + log.SetLevelOverride(field, value, log.ToLevel(level)) + } else if len(m) == 3 { + field, level := m[1], m[2] + log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value + } else { + return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override) + } + } + return nil } diff --git a/cmd/app_test.go b/cmd/app_test.go new file mode 100644 index 00000000..c5232050 --- /dev/null +++ b/cmd/app_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "github.com/urfave/cli/v2" + "os" + "strings" + "testing" +) + +// This only contains helpers so far + +func TestMain(m *testing.M) { + log.SetLevel(log.ErrorLevel) + os.Exit(m.Run()) +} + +func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { + var stdin, stdout, stderr bytes.Buffer + app := New() + app.Reader = &stdin + app.Writer = &stdout + app.ErrWriter = &stderr + return app, &stdin, &stdout, &stderr +} + +func toMessage(t *testing.T, s string) *client.Message { + var m *client.Message + if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { + t.Fatal(err) + } + return m +} diff --git a/cmd/config_loader.go b/cmd/config_loader.go new file mode 100644 index 00000000..eab742e2 --- /dev/null +++ b/cmd/config_loader.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "gopkg.in/yaml.v2" + "os" +) + +// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks +// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. +func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc { + return func(context *cli.Context) error { + configFile := context.String(configFlag) + if context.IsSet(configFlag) && !util.FileExists(configFile) { + return fmt.Errorf("config file %s does not exist", configFile) + } else if !context.IsSet(configFlag) && !util.FileExists(configFile) { + return nil + } + inputSource, err := newYamlSourceFromFile(configFile, flags) + if err != nil { + return err + } + if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil { + return err + } + if next != nil { + if err := next(context); err != nil { + return err + } + } + return nil + } +} + +// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath. +// +// This function also maps aliases, so a .yml file can contain short options, or options with underscores +// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255. +func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) { + var rawConfig map[any]any + b, err := os.ReadFile(file) + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(b, &rawConfig); err != nil { + return nil, err + } + for _, f := range flags { + flagName := f.Names()[0] + for _, flagAlias := range f.Names()[1:] { + if _, ok := rawConfig[flagAlias]; ok { + rawConfig[flagName] = rawConfig[flagAlias] + } + } + } + return altsrc.NewMapInputSource(file, rawConfig), nil +} diff --git a/cmd/config_loader_test.go b/cmd/config_loader_test.go new file mode 100644 index 00000000..7a7f2bf1 --- /dev/null +++ b/cmd/config_loader_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func TestNewYamlSourceFromFile(t *testing.T) { + filename := filepath.Join(t.TempDir(), "server.yml") + contents := ` +# Normal options +listen-https: ":10443" + +# Note the underscore! +listen_http: ":1080" + +# OMG this is allowed now ... +K: /some/file.pem +` + require.Nil(t, os.WriteFile(filename, []byte(contents), 0600)) + + ctx, err := newYamlSourceFromFile(filename, flagsServe) + require.Nil(t, err) + + listenHTTPS, err := ctx.String("listen-https") + require.Nil(t, err) + require.Equal(t, ":10443", listenHTTPS) + + listenHTTP, err := ctx.String("listen-http") // No underscore! + require.Nil(t, err) + require.Equal(t, ":1080", listenHTTP) + + keyFile, err := ctx.String("key-file") // Long option! + require.Nil(t, err) + require.Equal(t, "/some/file.pem", keyFile) +} diff --git a/cmd/publish.go b/cmd/publish.go new file mode 100644 index 00000000..050184ca --- /dev/null +++ b/cmd/publish.go @@ -0,0 +1,314 @@ +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func init() { + commands = append(commands, cmdPublish) +} + +var flagsPublish = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, + &cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, + &cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"}, + &cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, + &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"}, + &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"}, + &cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"}, + &cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"}, + &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, + &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, + &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, + &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, + &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, + &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, + &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, + &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, + &cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"}, + &cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"}, + &cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, + &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, + &cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"}, +) + +var cmdPublish = &cli.Command{ + Name: "publish", + Aliases: []string{"pub", "send", "trigger"}, + Usage: "Send message via a ntfy server", + UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...] +ntfy publish [OPTIONS..] --wait-cmd COMMAND... +NTFY_TOPIC=.. ntfy publish [OPTIONS..] [MESSAGE...]`, + Action: execPublish, + Category: categoryClient, + Flags: flagsPublish, + Before: initLogFunc, + Description: `Publish a message to a ntfy server. + +Examples: + ntfy publish mytopic This is my message # Send simple message + ntfy send myserver.com/mytopic "This is my message" # Send message to different default host + ntfy pub -p high backups "Backups failed" # Send high priority message + ntfy pub --tags=warning,skull backups "Backups failed" # Add tags/emojis to message + ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s + ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am + ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com + ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked + ntfy pub --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 -u phil:mypass secret Psst # Publish with username/password + ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing + ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes + NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password + NTFY_TOPIC=mytopic ntfy pub "some message" # Use NTFY_TOPIC variable as topic + cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment + ntfy trigger mywebhook # Sending without message, useful for webhooks + +Please also check out the docs on publishing messages. Especially for the --tags and --delay options, +it has incredibly useful information: https://ntfy.sh/docs/publish/. + +` + clientCommandDescriptionSuffix, +} + +func execPublish(c *cli.Context) error { + conf, err := loadConfig(c) + if err != nil { + return err + } + title := c.String("title") + priority := c.String("priority") + tags := c.String("tags") + delay := c.String("delay") + click := c.String("click") + icon := c.String("icon") + actions := c.String("actions") + attach := c.String("attach") + markdown := c.Bool("markdown") + filename := c.String("filename") + file := c.String("file") + email := c.String("email") + user := c.String("user") + token := c.String("token") + noCache := c.Bool("no-cache") + noFirebase := c.Bool("no-firebase") + quiet := c.Bool("quiet") + pid := c.Int("wait-pid") + + // Checks + if user != "" && token != "" { + return errors.New("cannot set both --user and --token") + } + + // Do the things + topic, message, command, err := parseTopicMessageCommand(c) + if err != nil { + return err + } + var options []client.PublishOption + if title != "" { + options = append(options, client.WithTitle(title)) + } + if priority != "" { + options = append(options, client.WithPriority(priority)) + } + if tags != "" { + options = append(options, client.WithTagsList(tags)) + } + if delay != "" { + options = append(options, client.WithDelay(delay)) + } + if click != "" { + options = append(options, client.WithClick(click)) + } + if icon != "" { + options = append(options, client.WithIcon(icon)) + } + if actions != "" { + options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " "))) + } + if attach != "" { + options = append(options, client.WithAttach(attach)) + } + if markdown { + options = append(options, client.WithMarkdown()) + } + if filename != "" { + options = append(options, client.WithFilename(filename)) + } + if email != "" { + options = append(options, client.WithEmail(email)) + } + if noCache { + options = append(options, client.WithNoCache()) + } + if noFirebase { + options = append(options, client.WithNoFirebase()) + } + if token != "" { + options = append(options, client.WithBearerAuth(token)) + } else if user != "" { + var pass string + parts := strings.SplitN(user, ":", 2) + if len(parts) == 2 { + user = parts[0] + pass = parts[1] + } else { + fmt.Fprint(c.App.ErrWriter, "Enter Password: ") + p, err := util.ReadPassword(c.App.Reader) + if err != nil { + return err + } + pass = string(p) + fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) + } + options = append(options, client.WithBasicAuth(user, pass)) + } else if conf.DefaultToken != "" { + options = append(options, client.WithBearerAuth(conf.DefaultToken)) + } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { + options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) + } + if pid > 0 { + newMessage, err := waitForProcess(pid) + if err != nil { + return err + } else if message == "" { + message = newMessage + } + } else if len(command) > 0 { + newMessage, err := runAndWaitForCommand(command) + if err != nil { + return err + } else if message == "" { + message = newMessage + } + } + var body io.Reader + if file == "" { + body = strings.NewReader(message) + } else { + if message != "" { + options = append(options, client.WithMessage(message)) + } + if file == "-" { + if filename == "" { + options = append(options, client.WithFilename("stdin")) + } + body = c.App.Reader + } else { + if filename == "" { + options = append(options, client.WithFilename(filepath.Base(file))) + } + body, err = os.Open(file) + if err != nil { + return err + } + } + } + cl := client.New(conf) + m, err := cl.PublishReader(topic, body, options...) + if err != nil { + return err + } + if !quiet { + fmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw)) + } + return nil +} + +// parseTopicMessageCommand reads the topic and the remaining arguments from the context. + +// There are a few cases to consider: +// +// ntfy publish [] +// ntfy publish --wait-cmd +// NTFY_TOPIC=.. ntfy publish [] +// NTFY_TOPIC=.. ntfy publish --wait-cmd +func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) { + var args []string + topic, args, err = parseTopicAndArgs(c) + if err != nil { + return + } + if c.Bool("wait-cmd") { + if len(args) == 0 { + err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help") + return + } + command = args + } else { + message = strings.Join(args, " ") + } + if c.String("message") != "" { + message = c.String("message") + } + return +} + +func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) { + envTopic := os.Getenv("NTFY_TOPIC") + if envTopic != "" { + topic = envTopic + return topic, remainingArgs(c, 0), nil + } + if c.NArg() < 1 { + return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help") + } + return c.Args().Get(0), remainingArgs(c, 1), nil +} + +func remainingArgs(c *cli.Context, fromIndex int) []string { + if c.NArg() > fromIndex { + return c.Args().Slice()[fromIndex:] + } + return []string{} +} + +func waitForProcess(pid int) (message string, err error) { + if !processExists(pid) { + return "", fmt.Errorf("process with PID %d not running", pid) + } + start := time.Now() + log.Debug("Waiting for process with PID %d to exit", pid) + for processExists(pid) { + time.Sleep(500 * time.Millisecond) + } + runtime := time.Since(start).Round(time.Millisecond) + log.Debug("Process with PID %d exited after %s", pid, runtime) + return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil +} + +func runAndWaitForCommand(command []string) (message string, err error) { + prettyCmd := util.QuoteCommand(command) + log.Debug("Running command: %s", prettyCmd) + start := time.Now() + cmd := exec.Command(command[0], command[1:]...) + if log.IsTrace() { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + err = cmd.Run() + runtime := time.Since(start).Round(time.Millisecond) + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd) + return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil + } + // Hard fail when command does not exist or could not be properly launched + return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, err.Error()) + } + log.Debug("Command succeeded after %s: %s", runtime, prettyCmd) + return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil +} diff --git a/cmd/publish_test.go b/cmd/publish_test.go new file mode 100644 index 00000000..fb4bbc70 --- /dev/null +++ b/cmd/publish_test.go @@ -0,0 +1,300 @@ +package cmd + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/test" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { + testMessage := util.RandomString(10) + app, _, _, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) + + _, err := util.Retry(func() (*int, error) { + app2, _, stdout, _ := newTestApp() + if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil { + return nil, err + } + if !strings.Contains(stdout.String(), testMessage) { + return nil, fmt.Errorf("test message %s not found in topic", testMessage) + } + return util.Int(1), nil + }, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s + require.Nil(t, err) +} + +func TestCLI_Publish_Subscribe_Poll(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", topic, "some message"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "some message", m.Message) + + app2, _, stdout, _ := newTestApp() + require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", topic})) + m = toMessage(t, stdout.String()) + require.Equal(t, "some message", m.Message) +} + +func TestCLI_Publish_All_The_Things(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{ + "ntfy", "publish", + "--title", "this is a title", + "--priority", "high", + "--tags", "tag1,tag2", + // No --delay, --email + "--click", "https://ntfy.sh", + "--icon", "https://ntfy.sh/static/img/ntfy.png", + "--attach", "https://f-droid.org/F-Droid.apk", + "--filename", "fdroid.apk", + "--no-cache", + "--no-firebase", + topic, + "some message", + })) + m := toMessage(t, stdout.String()) + require.Equal(t, "message", m.Event) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "some message", m.Message) + require.Equal(t, "this is a title", m.Title) + require.Equal(t, 4, m.Priority) + require.Equal(t, []string{"tag1", "tag2"}, m.Tags) + require.Equal(t, "https://ntfy.sh", m.Click) + require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL) + require.Equal(t, "fdroid.apk", m.Attachment.Name) + require.Equal(t, int64(0), m.Attachment.Size) + require.Equal(t, "", m.Attachment.Owner) + require.Equal(t, int64(0), m.Attachment.Expires) + require.Equal(t, "", m.Attachment.Type) + require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) +} + +func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) + + // Test: sleep 0.5 + sleep := exec.Command("sleep", "0.5") + require.Nil(t, sleep.Start()) + go sleep.Wait() // Must be called to release resources + start := time.Now() + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic})) + m := toMessage(t, stdout.String()) + require.True(t, time.Since(start) >= 500*time.Millisecond) + require.Regexp(t, `Process with PID \d+ exited after `, m.Message) + + // Test: PID does not exist + app, _, _, _ = newTestApp() + err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic}) + require.Error(t, err) + require.Equal(t, "process with PID 1234567 not running", err.Error()) + + // Test: Successful command (exit 0) + start = time.Now() + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"})) + m = toMessage(t, stdout.String()) + require.True(t, time.Since(start) >= 500*time.Millisecond) + require.Contains(t, m.Message, `Command succeeded after `) + require.Contains(t, m.Message, `: sleep 0.5`) + + // Test: Failing command (exit 1) + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"})) + m = toMessage(t, stdout.String()) + require.Contains(t, m.Message, `Command failed after `) + require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message) + + // Test: Non-existing command (hard fail!) + app, _, _, _ = newTestApp() + err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"}) + require.Error(t, err) + require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error()) + + // Tests with NTFY_TOPIC set //// + t.Setenv("NTFY_TOPIC", topic) + + // Test: Successful command with NTFY_TOPIC + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"})) + m = toMessage(t, stdout.String()) + require.Equal(t, "mytopic", m.Topic) + + // Test: Successful --wait-pid with NTFY_TOPIC + sleep = exec.Command("sleep", "0.2") + require.Nil(t, sleep.Start()) + go sleep.Wait() // Must be called to release resources + app, _, stdout, _ = newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)})) + m = toMessage(t, stdout.String()) + require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message) +} + +func TestCLI_Publish_Default_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: fakepass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "triggered", m.Message) +} + +func TestCLI_Publish_Token_And_UserPass(t *testing.T) { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + require.Error(t, err) + require.Equal(t, "cannot set both --user and --token", err.Error()) +} diff --git a/cmd/publish_unix.go b/cmd/publish_unix.go new file mode 100644 index 00000000..3ce22ffc --- /dev/null +++ b/cmd/publish_unix.go @@ -0,0 +1,11 @@ +//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd +// +build darwin linux dragonfly freebsd netbsd openbsd + +package cmd + +import "syscall" + +func processExists(pid int) bool { + err := syscall.Kill(pid, syscall.Signal(0)) + return err == nil +} diff --git a/cmd/publish_windows.go b/cmd/publish_windows.go new file mode 100644 index 00000000..92ce3112 --- /dev/null +++ b/cmd/publish_windows.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "os" +) + +func processExists(pid int) bool { + _, err := os.FindProcess(pid) + return err == nil +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 00000000..1177af0c --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,454 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "github.com/stripe/stripe-go/v74" + "io/fs" + "math" + "net" + "net/netip" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.zio.sh/astra/ntfy/v2/log" + + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" +) + +func init() { + commands = append(commands, cmdServe) +} + +const ( + defaultServerConfigFile = "/etc/ntfy/server.yml" +) + +var flagsServe = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}), + 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: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: 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)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}), + 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-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), + 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: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}), + 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)"}), + 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-')"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), + 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.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"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-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.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-public-key", Aliases: []string{"web_push_public_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PUBLIC_KEY"}, Usage: "public key used for web push notifications"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}), +) + +var cmdServe = &cli.Command{ + Name: "serve", + Usage: "Run the ntfy server", + UsageText: "ntfy serve [OPTIONS..]", + Action: execServe, + Category: categoryServer, + Flags: flagsServe, + Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc), + Description: `Run the ntfy server and listen for incoming requests + +The command will load the configuration from /etc/ntfy/server.yml. Config options can +be overridden using the command line options. + +Examples: + ntfy serve # Starts server in the foreground (on port 80) + ntfy serve --listen-http :8080 # Starts server with alternate port`, +} + +func execServe(c *cli.Context) error { + if c.NArg() > 0 { + return errors.New("no arguments expected, see 'ntfy serve --help' for help") + } + + // Read all the options + config := c.String("config") + baseURL := c.String("base-url") + listenHTTP := c.String("listen-http") + listenHTTPS := c.String("listen-https") + listenUnix := c.String("listen-unix") + listenUnixMode := c.Int("listen-unix-mode") + keyFile := c.String("key-file") + certFile := c.String("cert-file") + firebaseKeyFile := c.String("firebase-key-file") + webPushPrivateKey := c.String("web-push-private-key") + webPushPublicKey := c.String("web-push-public-key") + webPushFile := c.String("web-push-file") + webPushEmailAddress := c.String("web-push-email-address") + webPushStartupQueries := c.String("web-push-startup-queries") + cacheFile := c.String("cache-file") + cacheDuration := c.Duration("cache-duration") + cacheStartupQueries := c.String("cache-startup-queries") + cacheBatchSize := c.Int("cache-batch-size") + cacheBatchTimeout := c.Duration("cache-batch-timeout") + authFile := c.String("auth-file") + authStartupQueries := c.String("auth-startup-queries") + authDefaultAccess := c.String("auth-default-access") + attachmentCacheDir := c.String("attachment-cache-dir") + attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") + attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") + attachmentExpiryDuration := c.Duration("attachment-expiry-duration") + keepaliveInterval := c.Duration("keepalive-interval") + managerInterval := c.Duration("manager-interval") + disallowedTopics := c.StringSlice("disallowed-topics") + webRoot := c.String("web-root") + enableSignup := c.Bool("enable-signup") + enableLogin := c.Bool("enable-login") + enableReservations := c.Bool("enable-reservations") + upstreamBaseURL := c.String("upstream-base-url") + upstreamAccessToken := c.String("upstream-access-token") + smtpSenderAddr := c.String("smtp-sender-addr") + smtpSenderUser := c.String("smtp-sender-user") + smtpSenderPass := c.String("smtp-sender-pass") + smtpSenderFrom := c.String("smtp-sender-from") + smtpServerListen := c.String("smtp-server-listen") + smtpServerDomain := c.String("smtp-server-domain") + smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") + twilioAccount := c.String("twilio-account") + twilioAuthToken := c.String("twilio-auth-token") + twilioPhoneNumber := c.String("twilio-phone-number") + twilioVerifyService := c.String("twilio-verify-service") + totalTopicLimit := c.Int("global-topic-limit") + visitorSubscriptionLimit := c.Int("visitor-subscription-limit") + visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") + visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") + visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") + visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") + visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") + visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",") + visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") + visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") + visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") + behindProxy := c.Bool("behind-proxy") + stripeSecretKey := c.String("stripe-secret-key") + stripeWebhookKey := c.String("stripe-webhook-key") + billingContact := c.String("billing-contact") + metricsListenHTTP := c.String("metrics-listen-http") + enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" + profileListenHTTP := c.String("profile-listen-http") + + // Check values + if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { + return errors.New("if set, FCM key file must exist") + } 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 keepaliveInterval < 5*time.Second { + return errors.New("keepalive interval cannot be lower than five seconds") + } else if managerInterval < 5*time.Second { + return errors.New("manager interval cannot be lower than five seconds") + } else if cacheDuration > 0 && cacheDuration < managerInterval { + return errors.New("cache duration cannot be lower than manager interval") + } else if keyFile != "" && !util.FileExists(keyFile) { + return errors.New("if set, key file must exist") + } else if certFile != "" && !util.FileExists(certFile) { + return errors.New("if set, certificate file must exist") + } else if listenHTTPS != "" && (keyFile == "" || certFile == "") { + return errors.New("if listen-https is set, both key-file and cert-file must be set") + } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { + return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") + } else if smtpServerListen != "" && smtpServerDomain == "" { + return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") + } else if attachmentCacheDir != "" && baseURL == "" { + return errors.New("if attachment-cache-dir is set, base-url must also be set") + } else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + return errors.New("if set, base-url must start with http:// or https://") + } else if baseURL != "" && strings.HasSuffix(baseURL, "/") { + return errors.New("if set, base-url must not end with a slash (/)") + } else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") { + return errors.New("if set, upstream-base-url must start with http:// or https://") + } else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") { + return errors.New("if set, upstream-base-url must not end with a slash (/)") + } else if upstreamBaseURL != "" && baseURL == "" { + 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 || enableReservations || stripeSecretKey != "") { + return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") + } else if enableSignup && !enableLogin { + return errors.New("cannot set enable-signup without also setting enable-login") + } 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") + } + + // Backwards compatibility + if webRoot == "app" { + webRoot = "/" + } else if webRoot == "home" { + webRoot = "/app" + } else if webRoot == "disable" { + webRoot = "" + } else if !strings.HasPrefix(webRoot, "/") { + webRoot = "/" + webRoot + } + + // Default auth permissions + authDefault, err := user.ParsePermission(authDefaultAccess) + if err != nil { + return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") + } + + // Special case: Unset default + if listenHTTP == "-" { + listenHTTP = "" + } + + // Convert sizes to bytes + attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit) + if err != nil { + return err + } + attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit) + if err != nil { + return err + } + visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit) + if err != nil { + return err + } + visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit) + if err != nil { + return err + } else if visitorAttachmentDailyBandwidthLimit > math.MaxInt { + return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt) + } + + // Resolve hosts + visitorRequestLimitExemptIPs := make([]netip.Prefix, 0) + for _, host := range visitorRequestLimitExemptHosts { + ips, err := parseIPHostPrefix(host) + if err != nil { + log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) + continue + } + visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...) + } + + // Stripe things + if stripeSecretKey != "" { + stripe.EnableTelemetry = false // Whoa! + stripe.Key = stripeSecretKey + } + + // Add default forbidden topics + disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) + + // Run server + conf := server.NewConfig() + conf.File = config + conf.BaseURL = baseURL + conf.ListenHTTP = listenHTTP + conf.ListenHTTPS = listenHTTPS + conf.ListenUnix = listenUnix + conf.ListenUnixMode = fs.FileMode(listenUnixMode) + conf.KeyFile = keyFile + conf.CertFile = certFile + conf.FirebaseKeyFile = firebaseKeyFile + conf.CacheFile = cacheFile + conf.CacheDuration = cacheDuration + conf.CacheStartupQueries = cacheStartupQueries + conf.CacheBatchSize = cacheBatchSize + conf.CacheBatchTimeout = cacheBatchTimeout + conf.AuthFile = authFile + conf.AuthStartupQueries = authStartupQueries + conf.AuthDefault = authDefault + conf.AttachmentCacheDir = attachmentCacheDir + conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit + conf.AttachmentFileSizeLimit = attachmentFileSizeLimit + conf.AttachmentExpiryDuration = attachmentExpiryDuration + conf.KeepaliveInterval = keepaliveInterval + conf.ManagerInterval = managerInterval + conf.DisallowedTopics = disallowedTopics + conf.WebRoot = webRoot + conf.UpstreamBaseURL = upstreamBaseURL + conf.UpstreamAccessToken = upstreamAccessToken + conf.SMTPSenderAddr = smtpSenderAddr + conf.SMTPSenderUser = smtpSenderUser + conf.SMTPSenderPass = smtpSenderPass + conf.SMTPSenderFrom = smtpSenderFrom + conf.SMTPServerListen = smtpServerListen + conf.SMTPServerDomain = smtpServerDomain + conf.SMTPServerAddrPrefix = smtpServerAddrPrefix + conf.TwilioAccount = twilioAccount + conf.TwilioAuthToken = twilioAuthToken + conf.TwilioPhoneNumber = twilioPhoneNumber + conf.TwilioVerifyService = twilioVerifyService + conf.TotalTopicLimit = totalTopicLimit + conf.VisitorSubscriptionLimit = visitorSubscriptionLimit + conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit + conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit + conf.VisitorRequestLimitBurst = visitorRequestLimitBurst + conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish + conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs + conf.VisitorMessageDailyLimit = visitorMessageDailyLimit + conf.VisitorEmailLimitBurst = visitorEmailLimitBurst + conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish + conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting + conf.BehindProxy = behindProxy + conf.StripeSecretKey = stripeSecretKey + conf.StripeWebhookKey = stripeWebhookKey + conf.BillingContact = billingContact + conf.EnableSignup = enableSignup + conf.EnableLogin = enableLogin + conf.EnableReservations = enableReservations + conf.EnableMetrics = enableMetrics + conf.MetricsListenHTTP = metricsListenHTTP + conf.ProfileListenHTTP = profileListenHTTP + conf.Version = c.App.Version + conf.WebPushPrivateKey = webPushPrivateKey + conf.WebPushPublicKey = webPushPublicKey + conf.WebPushFile = webPushFile + conf.WebPushEmailAddress = webPushEmailAddress + conf.WebPushStartupQueries = webPushStartupQueries + + // Set up hot-reloading of config + go sigHandlerConfigReload(config) + + // Run server + s, err := server.New(conf) + if err != nil { + log.Fatal(err.Error()) + } else if err := s.Run(); err != nil { + log.Fatal(err.Error()) + } + log.Info("Exiting.") + return nil +} + +func parseSize(s string, defaultValue int64) (v int64, err error) { + if s == "" { + return defaultValue, nil + } + v, err = util.ParseSize(s) + if err != nil { + return 0, err + } + return v, nil +} + +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 + prefix, err := netip.ParsePrefix(host) + if err == nil { + prefixes = append(prefixes, prefix.Masked()) + return prefixes, nil + } + // Not a prefix, parse as host or IP (LookupHost passes through an IP as is) + ips, err := net.LookupHost(host) + if err != nil { + return nil, err + } + for _, ipStr := range ips { + ip, err := netip.ParseAddr(ipStr) + if err == nil { + prefix, err := ip.Prefix(ip.BitLen()) + if err != nil { + return nil, fmt.Errorf("%s successfully parsed but unable to make prefix: %s", ip.String(), err.Error()) + } + prefixes = append(prefixes, prefix.Masked()) + } + } + return +} + +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()) + } + overrides, err := inputSource.StringSlice("log-level-overrides") + if err != nil { + return fmt.Errorf("cannot load log level overrides (1): %s", err.Error()) + } + log.ResetLevelOverrides() + if err := applyLogLevelOverrides(overrides); err != nil { + return fmt.Errorf("cannot load log level overrides (2): %s", err.Error()) + } + 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 +} diff --git a/cmd/serve_test.go b/cmd/serve_test.go new file mode 100644 index 00000000..2fef0643 --- /dev/null +++ b/cmd/serve_test.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/test" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + rand.Seed(time.Now().UnixMilli()) +} + +func TestCLI_Serve_Unix_Curl(t *testing.T) { + sockFile := filepath.Join(t.TempDir(), "ntfy.sock") + configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system + go func() { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile}) + require.Nil(t, err) + }() + for i := 0; i < 40 && !util.FileExists(sockFile); i++ { + time.Sleep(50 * time.Millisecond) + } + require.True(t, util.FileExists(sockFile)) + + cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic") + out, err := cmd.Output() + require.Nil(t, err) + m := toMessage(t, string(out)) + require.Equal(t, "this is a message", m.Message) +} + +func TestCLI_Serve_WebSocket(t *testing.T) { + port := 10000 + rand.Intn(20000) + go func() { + configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)}) + require.Nil(t, err) + }() + test.WaitForPortUp(t, port) + + ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil) + require.Nil(t, err) + + messageType, data, err := ws.ReadMessage() + require.Nil(t, err) + require.Equal(t, websocket.TextMessage, messageType) + require.Equal(t, "open", toMessage(t, string(data)).Event) + + c := client.New(client.NewConfig()) + _, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message") + require.Nil(t, err) + + messageType, data, err = ws.ReadMessage() + require.Nil(t, err) + require.Equal(t, websocket.TextMessage, messageType) + + m := toMessage(t, string(data)) + require.Equal(t, "my message", m.Message) + require.Equal(t, "mytopic", m.Topic) +} + +func TestIP_Host_Parsing(t *testing.T) { + cases := map[string]string{ + "1.1.1.1": "1.1.1.1/32", + "fd00::1234": "fd00::1234/128", + "192.168.0.3/24": "192.168.0.0/24", + "10.1.2.3/8": "10.0.0.0/8", + "201:be93::4a6/21": "201:b800::/21", + } + for q, expectedAnswer := range cases { + ips, err := parseIPHostPrefix(q) + require.Nil(t, err) + assert.Equal(t, 1, len(ips)) + assert.Equal(t, expectedAnswer, ips[0].String()) + } +} + +func newEmptyFile(t *testing.T) string { + filename := filepath.Join(t.TempDir(), "empty") + require.Nil(t, os.WriteFile(filename, []byte{}, 0600)) + return filename +} diff --git a/cmd/subscribe.go b/cmd/subscribe.go new file mode 100644 index 00000000..c9ed75e9 --- /dev/null +++ b/cmd/subscribe.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/client" + "git.zio.sh/astra/ntfy/v2/log" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "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"}, + &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, + &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, + &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, + &cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"}, + &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, + &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, +) + +var cmdSubscribe = &cli.Command{ + Name: "subscribe", + Aliases: []string{"sub"}, + Usage: "Subscribe to one or more topics on a ntfy server", + UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]", + Action: execSubscribe, + Category: categoryClient, + Flags: flagsSubscribe, + Before: initLogFunc, + Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for +every arriving message. There are 3 modes in which the command can be run: + +ntfy subscribe TOPIC + This prints the JSON representation of every incoming message. It is useful when you + have a command that wants to stream-read incoming JSON messages. Unless --poll is passed, + this command stays open forever. + + Examples: + ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic + ntfy sub home.lan/backups # Subscribe to topic on different server + ntfy sub --poll home.lan/backups # Just query for latest messages and exit + ntfy sub -u phil:mypass secret # Subscribe with username/password + +ntfy subscribe TOPIC COMMAND + This executes COMMAND for every incoming messages. The message fields are passed to the + command as environment variables: + + Variable Aliases Description + --------------- --------------------- ----------------------------------- + $NTFY_ID $id Unique message ID + $NTFY_TIME $time Unix timestamp of the message delivery + $NTFY_TOPIC $topic Topic name + $NTFY_MESSAGE $message, $m Message body + $NTFY_TITLE $title, $t Message title + $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) + $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) + $NTFY_RAW $raw Raw JSON message + + Examples: + ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages + ntfy sub topic1 myscript.sh # Execute script for incoming messages + +ntfy subscribe --from-config + Service mode (used in ntfy-client.service). This reads the config file and sets up + subscriptions for every topic in the "subscribe:" block (see config file). + + Examples: + ntfy sub --from-config # Read topics from config file + ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file + +` + clientCommandDescriptionSuffix, +} + +func execSubscribe(c *cli.Context) error { + // Read config and options + conf, err := loadConfig(c) + if err != nil { + return err + } + cl := client.New(conf) + since := c.String("since") + user := c.String("user") + token := c.String("token") + poll := c.Bool("poll") + scheduled := c.Bool("scheduled") + fromConfig := c.Bool("from-config") + topic := c.Args().Get(0) + command := c.Args().Get(1) + + // Checks + if user != "" && token != "" { + return errors.New("cannot set both --user and --token") + } + + if !fromConfig { + conf.Subscribe = nil // wipe if --from-config not passed + } + var options []client.SubscribeOption + if since != "" { + options = append(options, client.WithSince(since)) + } + if token != "" { + options = append(options, client.WithBearerAuth(token)) + } else if user != "" { + var pass string + parts := strings.SplitN(user, ":", 2) + if len(parts) == 2 { + user = parts[0] + pass = parts[1] + } else { + fmt.Fprint(c.App.ErrWriter, "Enter Password: ") + p, err := util.ReadPassword(c.App.Reader) + if err != nil { + return err + } + pass = string(p) + fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) + } + options = append(options, client.WithBasicAuth(user, pass)) + } else if conf.DefaultToken != "" { + options = append(options, client.WithBearerAuth(conf.DefaultToken)) + } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { + options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) + } + if scheduled { + options = append(options, client.WithScheduled()) + } + if topic == "" && len(conf.Subscribe) == 0 { + return errors.New("must specify topic, type 'ntfy subscribe --help' for help") + } + + // Execute poll or subscribe + if poll { + return doPoll(c, cl, conf, topic, command, options...) + } + return doSubscribe(c, cl, conf, topic, command, options...) +} + +func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { + for _, s := range conf.Subscribe { // may be nil + if auth := maybeAddAuthHeader(s, conf); auth != nil { + options = append(options, auth) + } + if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil { + return err + } + } + if topic != "" { + if err := doPollSingle(c, cl, topic, command, options...); err != nil { + return err + } + } + return nil +} + +func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error { + messages, err := cl.Poll(topic, options...) + if err != nil { + return err + } + for _, m := range messages { + printMessageOrRunCommand(c, m, command) + } + return nil +} + +func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { + cmds := make(map[string]string) // Subscription ID -> command + for _, s := range conf.Subscribe { // May be nil + topicOptions := append(make([]client.SubscribeOption, 0), options...) + for filter, value := range s.If { + topicOptions = append(topicOptions, client.WithFilter(filter, value)) + } + + if auth := maybeAddAuthHeader(s, conf); auth != nil { + topicOptions = append(topicOptions, auth) + } + + subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...) + if err != nil { + return err + } + if s.Command != "" { + cmds[subscriptionID] = s.Command + } else if conf.DefaultCommand != "" { + cmds[subscriptionID] = conf.DefaultCommand + } else { + cmds[subscriptionID] = "" + } + } + if topic != "" { + subscriptionID, err := cl.Subscribe(topic, options...) + if err != nil { + return err + } + cmds[subscriptionID] = command + } + for m := range cl.Messages { + cmd, ok := cmds[m.SubscriptionID] + if !ok { + continue + } + log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw) + printMessageOrRunCommand(c, m, cmd) + } + return nil +} + +func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption { + // if an explicit empty token or empty user:pass is given, exit without auth + if (s.Token != nil && *s.Token == "") || (s.User != nil && *s.User == "" && s.Password != nil && *s.Password == "") { + return client.WithEmptyAuth() + } + + // check for subscription token then subscription user:pass + if s.Token != nil && *s.Token != "" { + return client.WithBearerAuth(*s.Token) + } + if s.User != nil && *s.User != "" && s.Password != nil { + return client.WithBasicAuth(*s.User, *s.Password) + } + + // if no subscription token nor subscription user:pass, check for default token then default user:pass + if conf.DefaultToken != "" { + return client.WithBearerAuth(conf.DefaultToken) + } + if conf.DefaultUser != "" && conf.DefaultPassword != nil { + return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword) + } + return nil +} + +func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) { + if command != "" { + runCommand(c, command, m) + } else { + log.Debug("%s Printing raw message", logMessagePrefix(m)) + fmt.Fprintln(c.App.Writer, m.Raw) + } +} + +func runCommand(c *cli.Context, command string, m *client.Message) { + if err := runCommandInternal(c, command, m); err != nil { + log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error()) + } +} + +func runCommandInternal(c *cli.Context, script string, m *client.Message) error { + scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt) + log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile) + script = scriptHeader + script + if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil { + return err + } + defer os.Remove(scriptFile) + log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile) + cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...) + cmd.Stdin = c.App.Reader + cmd.Stdout = c.App.Writer + cmd.Stderr = c.App.ErrWriter + cmd.Env = envVars(m) + return cmd.Run() +} + +func envVars(m *client.Message) []string { + env := make([]string, 0) + env = append(env, envVar(m.ID, "NTFY_ID", "id")...) + env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...) + env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...) + env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...) + env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...) + env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...) + env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...) + env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...) + sort.Strings(env) + if log.IsTrace() { + log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n")) + } + return append(os.Environ(), env...) +} + +func envVar(value string, vars ...string) []string { + env := make([]string, 0) + for _, v := range vars { + env = append(env, fmt.Sprintf("%s=%s", v, value)) + } + return env +} + +func loadConfig(c *cli.Context) (*client.Config, error) { + filename := c.String("config") + if filename != "" { + return client.LoadConfig(filename) + } + configFile := defaultClientConfigFile() + if s, _ := os.Stat(configFile); s != nil { + return client.LoadConfig(configFile) + } + return client.NewConfig(), nil +} + +//lint:ignore U1000 Conditionally used in different builds +func defaultClientConfigFileUnix() string { + u, _ := user.Current() + configFile := clientRootConfigFileUnixAbsolute + if u.Uid != "0" { + homeDir, _ := os.UserConfigDir() + return filepath.Join(homeDir, clientUserConfigFileUnixRelative) + } + return configFile +} + +//lint:ignore U1000 Conditionally used in different builds +func defaultClientConfigFileWindows() string { + homeDir, _ := os.UserConfigDir() + return filepath.Join(homeDir, clientUserConfigFileWindowsRelative) +} + +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 new file mode 100644 index 00000000..0372a79f --- /dev/null +++ b/cmd/subscribe_darwin.go @@ -0,0 +1,16 @@ +package cmd + +const ( + scriptExt = "sh" + scriptHeader = "#!/bin/sh\n" + clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user), +or "~/Library/Application Support/ntfy/client.yml" for all other users.` +) + +var ( + scriptLauncher = []string{"sh", "-c"} +) + +func defaultClientConfigFile() string { + return defaultClientConfigFileUnix() +} diff --git a/cmd/subscribe_test.go b/cmd/subscribe_test.go new file mode 100644 index 00000000..08dbbf5d --- /dev/null +++ b/cmd/subscribe_test.go @@ -0,0 +1,417 @@ +package cmd + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: fake +default-password: password +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +subscribe: + - topic: mytopic + user: philipp + password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN0123456789FAKETOKEN +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_FAKETOKEN01234567890FAKETOKEN +subscribe: + - topic: mytopic + token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) + require.Error(t, err) + require.Equal(t, "cannot set both --user and --token", err.Error()) +} + +func TestCLI_Subscribe_Default_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Default_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "mytopic"})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Override_Default_UserPass_With_Empty_UserPass(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-user: philipp +default-password: mypass +subscribe: + - topic: mytopic + user: "" + password: "" +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} + +func TestCLI_Subscribe_Override_Default_Token_With_Empty_Token(t *testing.T) { + message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic/json", r.URL.Path) + require.Equal(t, "", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(message)) + })) + defer server.Close() + + filename := filepath.Join(t.TempDir(), "client.yml") + require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` +default-host: %s +default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 +subscribe: + - topic: mytopic + token: "" +`, server.URL)), 0600)) + + app, _, stdout, _ := newTestApp() + + require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) + + require.Equal(t, message, strings.TrimSpace(stdout.String())) +} diff --git a/cmd/subscribe_unix.go b/cmd/subscribe_unix.go new file mode 100644 index 00000000..8b91fed9 --- /dev/null +++ b/cmd/subscribe_unix.go @@ -0,0 +1,18 @@ +//go:build linux || dragonfly || freebsd || netbsd || openbsd + +package cmd + +const ( + scriptExt = "sh" + scriptHeader = "#!/bin/sh\n" + clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user), +or ~/.config/ntfy/client.yml for all other users.` +) + +var ( + scriptLauncher = []string{"sh", "-c"} +) + +func defaultClientConfigFile() string { + return defaultClientConfigFileUnix() +} diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go new file mode 100644 index 00000000..e8f1a271 --- /dev/null +++ b/cmd/subscribe_windows.go @@ -0,0 +1,15 @@ +package cmd + +const ( + scriptExt = "bat" + scriptHeader = "" + clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.` +) + +var ( + scriptLauncher = []string{"cmd.exe", "/Q", "/C"} +) + +func defaultClientConfigFile() string { + return defaultClientConfigFileWindows() +} diff --git a/cmd/tier.go b/cmd/tier.go new file mode 100644 index 00000000..76e273a1 --- /dev/null +++ b/cmd/tier.go @@ -0,0 +1,374 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" +) + +func init() { + commands = append(commands, cmdTier) +} + +const ( + defaultMessageLimit = 5000 + defaultMessageExpiryDuration = "12h" + defaultEmailLimit = 20 + defaultCallLimit = 0 + defaultReservationLimit = 3 + defaultAttachmentFileSizeLimit = "15M" + defaultAttachmentTotalSizeLimit = "100M" + defaultAttachmentExpiryDuration = "6h" + defaultAttachmentBandwidthLimit = "1G" +) + +var ( + flagsTier = append([]cli.Flag{}, flagsUser...) +) + +var cmdTier = &cli.Command{ + Name: "tier", + Usage: "Manage/show tiers", + UsageText: "ntfy tier [list|add|change|remove] ...", + Flags: flagsTier, + Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), + Category: categoryServer, + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "Adds a new tier", + UsageText: "ntfy tier add [OPTIONS] CODE", + Action: execTierAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "name", Usage: "tier name"}, + &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, + &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, + &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, + &cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"}, + &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, + &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, + &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, + &cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"}, + &cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"}, + &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"}, + }, + Description: `Add a new tier to the ntfy user database. + +Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or +make it possible for users to reserve topics. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy tier add pro # Add tier with code "pro", using the defaults + ntfy tier add \ # Add a tier with custom limits + --name="Pro" \ + --message-limit=10000 \ + --message-expiry-duration=24h \ + --email-limit=50 \ + --reservation-limit=10 \ + --attachment-file-size-limit=100M \ + --attachment-total-size-limit=1G \ + --attachment-expiry-duration=12h \ + --attachment-bandwidth-limit=5G \ + pro +`, + }, + { + Name: "change", + Aliases: []string{"ch"}, + Usage: "Change a tier", + UsageText: "ntfy tier change [OPTIONS] CODE", + Action: execTierChange, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "name", Usage: "tier name"}, + &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, + &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, + &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, + &cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"}, + &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, + &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, + &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, + &cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"}, + &cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"}, + &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, + }, + Description: `Updates a tier to change the limits. + +After updating a tier, you may have to restart the ntfy server to apply them +to all visitors. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy tier change --name="Pro" pro # Update the name of an existing tier + ntfy tier change \ # Update multiple limits and fields + --message-expiry-duration=24h \ + --stripe-monthly-price-id=price_1234 \ + --stripe-monthly-price-id=price_5678 \ + pro +`, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "Removes a tier", + UsageText: "ntfy tier remove CODE", + Action: execTierDel, + Description: `Remove a tier from the ntfy user database. + +You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier" +to remove or switch their tier first. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Example: + ntfy tier del pro +`, + }, + { + Name: "list", + Aliases: []string{"l"}, + Usage: "Shows a list of tiers", + Action: execTierList, + Description: `Shows a list of all configured tiers. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. +`, + }, + }, + Description: `Manage tiers of the ntfy server. + +The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used +to grant users higher limits, such as daily message limits, attachment size, or make it +possible for users to reserve topics. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy tier add pro # Add tier with code "pro", using the defaults + ntfy tier change --name="Pro" pro # Update the name of an existing tier + ntfy tier del pro # Delete an existing tier +`, +} + +func execTierAdd(c *cli.Context) error { + code := c.Args().Get(0) + if code == "" { + return errors.New("tier code expected, type 'ntfy tier add --help' for help") + } else if !user.AllowedTier(code) { + return errors.New("tier code must consist only of numbers and letters") + } else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" { + return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") + } else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" { + return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if tier, _ := manager.Tier(code); tier != nil { + if c.Bool("ignore-exists") { + fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code) + return nil + } + return fmt.Errorf("tier %s already exists", code) + } + name := c.String("name") + if name == "" { + name = code + } + messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration")) + if err != nil { + return err + } + attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit")) + if err != nil { + return err + } + attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit")) + if err != nil { + return err + } + attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit")) + if err != nil { + return err + } + attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration")) + if err != nil { + return err + } + tier := &user.Tier{ + ID: "", // Generated + Code: code, + Name: name, + MessageLimit: c.Int64("message-limit"), + MessageExpiryDuration: messageExpiryDuration, + EmailLimit: c.Int64("email-limit"), + CallLimit: c.Int64("call-limit"), + ReservationLimit: c.Int64("reservation-limit"), + AttachmentFileSizeLimit: attachmentFileSizeLimit, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit, + AttachmentExpiryDuration: attachmentExpiryDuration, + AttachmentBandwidthLimit: attachmentBandwidthLimit, + StripeMonthlyPriceID: c.String("stripe-monthly-price-id"), + StripeYearlyPriceID: c.String("stripe-yearly-price-id"), + } + if err := manager.AddTier(tier); err != nil { + return err + } + tier, err = manager.Tier(code) + if err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "tier added\n\n") + printTier(c, tier) + return nil +} + +func execTierChange(c *cli.Context) error { + code := c.Args().Get(0) + if code == "" { + return errors.New("tier code expected, type 'ntfy tier change --help' for help") + } else if !user.AllowedTier(code) { + return errors.New("tier code must consist only of numbers and letters") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + tier, err := manager.Tier(code) + if err == user.ErrTierNotFound { + return fmt.Errorf("tier %s does not exist", code) + } else if err != nil { + return err + } + if c.IsSet("name") { + tier.Name = c.String("name") + } + if c.IsSet("message-limit") { + tier.MessageLimit = c.Int64("message-limit") + } + if c.IsSet("message-expiry-duration") { + tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration")) + if err != nil { + return err + } + } + if c.IsSet("email-limit") { + tier.EmailLimit = c.Int64("email-limit") + } + if c.IsSet("call-limit") { + tier.CallLimit = c.Int64("call-limit") + } + if c.IsSet("reservation-limit") { + tier.ReservationLimit = c.Int64("reservation-limit") + } + if c.IsSet("attachment-file-size-limit") { + tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit")) + if err != nil { + return err + } + } + if c.IsSet("attachment-total-size-limit") { + tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit")) + if err != nil { + return err + } + } + if c.IsSet("attachment-expiry-duration") { + tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration")) + if err != nil { + return err + } + } + if c.IsSet("attachment-bandwidth-limit") { + tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit")) + if err != nil { + return err + } + } + if c.IsSet("stripe-monthly-price-id") { + tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id") + } + if c.IsSet("stripe-yearly-price-id") { + tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id") + } + if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" { + return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") + } else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" { + return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") + } + if err := manager.UpdateTier(tier); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n") + printTier(c, tier) + return nil +} + +func execTierDel(c *cli.Context) error { + code := c.Args().Get(0) + if code == "" { + return errors.New("tier code expected, type 'ntfy tier del --help' for help") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.Tier(code); err == user.ErrTierNotFound { + return fmt.Errorf("tier %s does not exist", code) + } + if err := manager.RemoveTier(code); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code) + return nil +} + +func execTierList(c *cli.Context) error { + manager, err := createUserManager(c) + if err != nil { + return err + } + tiers, err := manager.Tiers() + if err != nil { + return err + } + for _, tier := range tiers { + printTier(c, tier) + } + return nil +} + +func printTier(c *cli.Context, tier *user.Tier) { + prices := "(none)" + if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { + prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) + } + fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID) + fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name) + fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) + fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) + fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) + fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) + fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) + fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) + fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit)) + fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) +} diff --git a/cmd/tier_test.go b/cmd/tier_test.go new file mode 100644 index 00000000..b896d15f --- /dev/null +++ b/cmd/tier_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "testing" +) + +func TestCLI_Tier_AddListChangeDelete(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, _, _, stderr := newTestApp() + require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro")) + require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_") + + err := runTierCommand(app, conf, "add", "pro") + require.NotNil(t, err) + require.Equal(t, "tier pro already exists", err.Error()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTierCommand(app, conf, "list")) + require.Contains(t, stderr.String(), "tier pro (id: ti_") + require.Contains(t, stderr.String(), "- Name: Pro") + require.Contains(t, stderr.String(), "- Message limit: 1234") + + app, _, _, stderr = newTestApp() + require.Nil(t, runTierCommand(app, conf, "change", + "--message-limit=999", + "--message-expiry-duration=2d", + "--email-limit=91", + "--reservation-limit=98", + "--attachment-file-size-limit=100m", + "--attachment-expiry-duration=1d", + "--attachment-total-size-limit=10G", + "--attachment-bandwidth-limit=100G", + "--stripe-monthly-price-id=price_991", + "--stripe-yearly-price-id=price_992", + "pro", + )) + require.Contains(t, stderr.String(), "- Message limit: 999") + require.Contains(t, stderr.String(), "- Message expiry duration: 48h") + require.Contains(t, stderr.String(), "- Email limit: 91") + require.Contains(t, stderr.String(), "- Reservation limit: 98") + require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB") + require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h") + require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB") + require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") + + app, _, _, stderr = newTestApp() + require.Nil(t, runTierCommand(app, conf, "remove", "pro")) + require.Contains(t, stderr.String(), "tier pro removed") +} + +func runTierCommand(app *cli.App, conf *server.Config, args ...string) error { + userArgs := []string{ + "ntfy", + "--log-level=ERROR", + "tier", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file + "--auth-file=" + conf.AuthFile, + "--auth-default-access=" + conf.AuthDefault.String(), + } + return app.Run(append(userArgs, args...)) +} diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 00000000..4fe7e541 --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,210 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "net/netip" + "time" +) + +func init() { + commands = append(commands, cmdToken) +} + +var flagsToken = append([]cli.Flag{}, flagsUser...) + +var cmdToken = &cli.Command{ + Name: "token", + Usage: "Create, list or delete user tokens", + UsageText: "ntfy token [list|add|remove] ...", + Flags: flagsToken, + Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc), + Category: categoryServer, + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "Create a new token", + UsageText: "ntfy token add [--expires=] [--label=..] USERNAME", + Action: execTokenAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"}, + &cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"}, + }, + Description: `Create a new user access token. + +User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. +Tokens have full access, and can perform any task a user can do. They are meant to be used to +avoid spreading the password to various places. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy token add phil # Create token for user phil which never expires + ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days + ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday + ntfy token add -l backups phil # Create token for user phil with label "backups"`, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "Removes a token", + UsageText: "ntfy token remove USERNAME TOKEN", + Action: execTokenDel, + Description: `Remove a token from the ntfy user database. + +Example: + ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`, + }, + { + Name: "list", + Aliases: []string{"l"}, + Usage: "Shows a list of tokens", + Action: execTokenList, + Description: `Shows a list of all tokens. + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined.`, + }, + }, + Description: `Manage access tokens for individual users. + +User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. +Tokens have full access, and can perform any task a user can do. They are meant to be used to +avoid spreading the password to various places. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy token list # Shows list of tokens for all users + ntfy token list phil # Shows list of tokens for user phil + ntfy token add phil # Create token for user phil which never expires + ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days + ntfy token remove phil tk_th2srHVlxr... # Delete token`, +} + +func execTokenAdd(c *cli.Context) error { + username := c.Args().Get(0) + expiresStr := c.String("expires") + label := c.String("label") + if username == "" { + return errors.New("username expected, type 'ntfy token add --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + expires := time.Unix(0, 0) + if expiresStr != "" { + var err error + expires, err = util.ParseFutureTime(expiresStr, time.Now()) + if err != nil { + return err + } + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) + if err != nil { + return err + } + if expires.Unix() == 0 { + fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name) + } else { + fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) + } + return nil +} + +func execTokenDel(c *cli.Context) error { + username, token := c.Args().Get(0), c.Args().Get(1) + if username == "" || token == "" { + return errors.New("username and token expected, type 'ntfy token remove --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + if err := manager.RemoveToken(u.ID, token); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username) + return nil +} + +func execTokenList(c *cli.Context) error { + username := c.Args().Get(0) + if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + var users []*user.User + if username != "" { + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + users = append(users, u) + } else { + users, err = manager.Users() + if err != nil { + return err + } + } + usersWithTokens := 0 + for _, u := range users { + tokens, err := manager.Tokens(u.ID) + if err != nil { + return err + } else if len(tokens) == 0 && username != "" { + fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username) + return nil + } else if len(tokens) == 0 { + continue + } + usersWithTokens++ + fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) + for _, t := range tokens { + var label, expires string + if t.Label != "" { + label = fmt.Sprintf(" (%s)", t.Label) + } + if t.Expires.Unix() == 0 { + expires = "never expires" + } else { + expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) + } + fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) + } + } + if usersWithTokens == 0 { + fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n") + } + return nil +} diff --git a/cmd/token_test.go b/cmd/token_test.go new file mode 100644 index 00000000..b5f0c0b4 --- /dev/null +++ b/cmd/token_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "regexp" + "testing" +) + +func TestCLI_Token_AddListRemove(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "add", "phil")) + require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "list", "phil")) + require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String()) + re := regexp.MustCompile(`tk_\w+`) + token := re.FindString(stderr.String()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token)) + require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String()) + + app, _, _, stderr = newTestApp() + require.Nil(t, runTokenCommand(app, conf, "list")) + require.Equal(t, "no users with tokens\n", stderr.String()) +} + +func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error { + userArgs := []string{ + "ntfy", + "--log-level=ERROR", + "token", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file + "--auth-file=" + conf.AuthFile, + } + return app.Run(append(userArgs, args...)) +} diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 00000000..21fe21af --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,357 @@ +//go:build !noserver + +package cmd + +import ( + "crypto/subtle" + "errors" + "fmt" + "git.zio.sh/astra/ntfy/v2/user" + "os" + "strings" + + "git.zio.sh/astra/ntfy/v2/util" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" +) + +const ( + tierReset = "-" +) + +func init() { + commands = append(commands, cmdUser) +} + +var flagsUser = append( + append([]cli.Flag{}, flagsDefault...), + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "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"}), +) + +var cmdUser = &cli.Command{ + Name: "user", + Usage: "Manage/show users", + UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", + Flags: flagsUser, + Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), + Category: categoryServer, + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "Adds a new user", + UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME", + Action: execUserAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"}, + &cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"}, + }, + Description: `Add a new user to the ntfy user database. + +A user can be either a regular user, or an admin. A regular user has no read or write access (unless +granted otherwise by the auth-default-access setting). An admin user has read and write access to all +topics. + +Examples: + ntfy user add phil # Add regular user phil + ntfy user add --role=admin phil # Add admin user phil + NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts) + +You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if +you are creating users via scripts. +`, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "Removes a user", + UsageText: "ntfy user remove USERNAME", + Action: execUserDel, + Description: `Remove a user from the ntfy user database. + +Example: + ntfy user del phil +`, + }, + { + Name: "change-pass", + Aliases: []string{"chp"}, + Usage: "Changes a user's password", + UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME", + Action: execUserChangePass, + Description: `Change the password for the given user. + +The new password will be read from STDIN, and it'll be confirmed by typing +it twice. + +Example: + ntfy user change-pass phil + NTFY_PASSWORD=.. ntfy user change-pass phil + +You may set the NTFY_PASSWORD environment variable to pass the new password. This is +useful if you are updating users via scripts. + +`, + }, + { + Name: "change-role", + Aliases: []string{"chr"}, + Usage: "Changes the role of a user", + UsageText: "ntfy user change-role USERNAME ROLE", + Action: execUserChangeRole, + Description: `Change the role for the given user to admin or user. + +This command can be used to change the role of a user either from a regular user +to an admin user, or the other way around: + +- admin: an admin has read/write access to all topics +- user: a regular user only has access to what was explicitly granted via 'ntfy access' + +When changing the role of a user to "admin", all access control entries for that +user are removed, since they are no longer necessary. + +Example: + ntfy user change-role phil admin # Make user phil an admin + ntfy user change-role phil user # Remove admin role from user phil +`, + }, + { + Name: "change-tier", + Aliases: []string{"cht"}, + Usage: "Changes the tier of a user", + UsageText: "ntfy user change-tier USERNAME (TIER|-)", + Action: execUserChangeTier, + Description: `Change the tier for the given user. + +This command can be used to change the tier of a user. Tiers define usage limits, such +as messages per day, attachment file sizes, etc. + +Example: + ntfy user change-tier phil pro # Change tier to "pro" for user "phil" + ntfy user change-tier phil - # Remove tier from user "phil" entirely +`, + }, + { + Name: "list", + Aliases: []string{"l"}, + Usage: "Shows a list of users", + Action: execUserList, + Description: `Shows a list of all configured users, including the everyone ('*') user. + +This command is an alias to calling 'ntfy access' (display access control list). + +This is a server-only command. It directly reads from user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. +`, + }, + }, + Description: `Manage users of the ntfy server. + +The command allows you to add/remove/change users in the ntfy user database, as well as change +passwords or roles. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. Please also refer +to the related command 'ntfy access'. + +Examples: + ntfy user list # Shows list of users (alias: 'ntfy access') + ntfy user add phil # Add regular user phil + NTFY_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts) + ntfy user add --role=admin phil # Add admin user phil + ntfy user del phil # Delete user phil + ntfy user change-pass phil # Change password for user phil + NTFY_PASSWORD=.. ntfy user change-pass phil # As above, using env variable to set password (for scripts) + ntfy user change-role phil admin # Make user phil an admin + +For the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment +variable to pass the new password. This is useful if you are creating/updating users via scripts. +`, +} + +func execUserAdd(c *cli.Context) error { + username := c.Args().Get(0) + role := user.Role(c.String("role")) + password := os.Getenv("NTFY_PASSWORD") + if username == "" { + return errors.New("username expected, type 'ntfy user add --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } else if !user.AllowedRole(role) { + return errors.New("role must be either 'user' or 'admin'") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if user, _ := manager.User(username); user != nil { + if c.Bool("ignore-exists") { + fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username) + return nil + } + return fmt.Errorf("user %s already exists", username) + } + if password == "" { + p, err := readPasswordAndConfirm(c) + if err != nil { + return err + } + + password = p + } + if err := manager.AddUser(username, password, role); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role) + return nil +} + +func execUserDel(c *cli.Context) error { + username := c.Args().Get(0) + if username == "" { + return errors.New("username expected, type 'ntfy user del --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.User(username); err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } + if err := manager.RemoveUser(username); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username) + return nil +} + +func execUserChangePass(c *cli.Context) error { + username := c.Args().Get(0) + password := os.Getenv("NTFY_PASSWORD") + if username == "" { + return errors.New("username expected, type 'ntfy user change-pass --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.User(username); err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } + if password == "" { + password, err = readPasswordAndConfirm(c) + if err != nil { + return err + } + } + if err := manager.ChangePassword(username, password); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username) + return nil +} + +func execUserChangeRole(c *cli.Context) error { + username := c.Args().Get(0) + role := user.Role(c.Args().Get(1)) + if username == "" || !user.AllowedRole(role) { + return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.User(username); err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } + if err := manager.ChangeRole(username, role); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role) + return nil +} + +func execUserChangeTier(c *cli.Context) error { + username := c.Args().Get(0) + tier := c.Args().Get(1) + if username == "" { + return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help") + } else if !user.AllowedTier(tier) && tier != tierReset { + return errors.New("invalid tier, must be tier code, or - to reset") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + if _, err := manager.User(username); err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } + if tier == tierReset { + if err := manager.ResetTier(username); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username) + } else { + if err := manager.ChangeTier(username, tier); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier) + } + return nil +} + +func execUserList(c *cli.Context) error { + manager, err := createUserManager(c) + if err != nil { + return err + } + users, err := manager.Users() + if err != nil { + return err + } + return showUsers(c, manager, users) +} + +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") + } + 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'") + } + return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval) +} + +func readPasswordAndConfirm(c *cli.Context) (string, error) { + fmt.Fprint(c.App.ErrWriter, "password: ") + password, err := util.ReadPassword(c.App.Reader) + if err != nil { + return "", err + } + fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25)) + confirm, err := util.ReadPassword(c.App.Reader) + if err != nil { + return "", err + } + fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25)) + if subtle.ConstantTimeCompare(confirm, password) != 1 { + return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly") + } + return string(password), nil +} diff --git a/cmd/user_test.go b/cmd/user_test.go new file mode 100644 index 00000000..361a4288 --- /dev/null +++ b/cmd/user_test.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "git.zio.sh/astra/ntfy/v2/server" + "git.zio.sh/astra/ntfy/v2/test" + "git.zio.sh/astra/ntfy/v2/user" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "os" + "path/filepath" + "testing" +) + +func TestCLI_User_Add(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") +} + +func TestCLI_User_Add_Exists(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + app, stdin, _, _ = newTestApp() + stdin.WriteString("mypass\nmypass") + err := runUserCommand(app, conf, "add", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "user phil already exists") +} + +func TestCLI_User_Add_Admin(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) + require.Contains(t, stderr.String(), "user phil added with role admin") +} + +func TestCLI_User_Add_Password_Mismatch(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nNOTMATCH") + err := runUserCommand(app, conf, "add", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly") +} + +func TestCLI_User_ChangePass(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Add user + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + // Change pass + app, stdin, _, stderr = newTestApp() + stdin.WriteString("newpass\nnewpass") + require.Nil(t, runUserCommand(app, conf, "change-pass", "phil")) + require.Contains(t, stderr.String(), "changed password for user phil") +} + +func TestCLI_User_ChangeRole(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Add user + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + // Change role + app, _, _, stderr = newTestApp() + require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) + require.Contains(t, stderr.String(), "changed role for user phil to admin") +} + +func TestCLI_User_Delete(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Add user + app, stdin, _, stderr := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + require.Contains(t, stderr.String(), "user phil added with role user") + + // Delete user + app, _, _, stderr = newTestApp() + require.Nil(t, runUserCommand(app, conf, "del", "phil")) + require.Contains(t, stderr.String(), "user phil removed") + + // Delete user again (does not exist) + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "del", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "user phil does not exist") +} + +func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { + 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 + conf = server.NewConfig() + conf.File = configFile + conf.AuthFile = filepath.Join(t.TempDir(), "user.db") + conf.AuthDefault = user.PermissionDenyAll + s, port = test.StartServerWithConfig(t, conf) + return +} + +func runUserCommand(app *cli.App, conf *server.Config, args ...string) error { + userArgs := []string{ + "ntfy", + "--log-level=ERROR", + "user", + "--config=" + conf.File, // Dummy config file to avoid lookups of real file + "--auth-file=" + conf.AuthFile, + "--auth-default-access=" + conf.AuthDefault.String(), + } + return app.Run(append(userArgs, args...)) +} diff --git a/cmd/webpush.go b/cmd/webpush.go new file mode 100644 index 00000000..ec66f083 --- /dev/null +++ b/cmd/webpush.go @@ -0,0 +1,48 @@ +//go:build !noserver + +package cmd + +import ( + "fmt" + + "github.com/SherClockHolmes/webpush-go" + "github.com/urfave/cli/v2" +) + +func init() { + commands = append(commands, cmdWebPush) +} + +var cmdWebPush = &cli.Command{ + Name: "webpush", + Usage: "Generate keys, in the future manage web push subscriptions", + UsageText: "ntfy webpush [keys]", + Category: categoryServer, + + Subcommands: []*cli.Command{ + { + Action: generateWebPushKeys, + Name: "keys", + Usage: "Generate VAPID keys to enable browser background push notifications", + UsageText: "ntfy webpush keys", + Category: categoryServer, + }, + }, +} + +func generateWebPushKeys(c *cli.Context) error { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file: + +web-push-public-key: %s +web-push-private-key: %s +web-push-file: /var/cache/ntfy/webpush.db # or similar +web-push-email-address: + +See https://ntfy.sh/docs/config/#web-push for details. +`, publicKey, privateKey) + return err +} diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go new file mode 100644 index 00000000..e2565214 --- /dev/null +++ b/cmd/webpush_test.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "testing" + + "git.zio.sh/astra/ntfy/v2/server" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +func TestCLI_WebPush_GenerateKeys(t *testing.T) { + app, _, _, stderr := newTestApp() + require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys")) + require.Contains(t, stderr.String(), "Web Push keys generated.") +} + +func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error { + webPushArgs := []string{ + "ntfy", + "--log-level=ERROR", + "webpush", + } + return app.Run(append(webPushArgs, args...)) +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index b244349b..00000000 --- a/config/config.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package config provides the main configuration -package config - -import ( - "golang.org/x/time/rate" - "time" -) - -// Defines default config settings -const ( - DefaultListenHTTP = ":80" - DefaultCacheDuration = 12 * time.Hour - DefaultKeepaliveInterval = 30 * time.Second - DefaultManagerInterval = time.Minute -) - -// Defines all the limits -// - request limit: max number of PUT/GET/.. requests (here: 50 requests bucket, replenished at a rate of one per 10 seconds) -// - global topic limit: max number of topics overall -// - subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP -var ( - defaultGlobalTopicLimit = 5000 - defaultVisitorRequestLimit = rate.Every(10 * time.Second) - defaultVisitorRequestLimitBurst = 50 - defaultVisitorSubscriptionLimit = 30 -) - -// Config is the main config struct for the application. Use New to instantiate a default config struct. -type Config struct { - ListenHTTP string - FirebaseKeyFile string - CacheFile string - CacheDuration time.Duration - KeepaliveInterval time.Duration - ManagerInterval time.Duration - GlobalTopicLimit int - VisitorRequestLimit rate.Limit - VisitorRequestLimitBurst int - VisitorSubscriptionLimit int -} - -// New instantiates a default new config -func New(listenHTTP string) *Config { - return &Config{ - ListenHTTP: listenHTTP, - FirebaseKeyFile: "", - CacheFile: "", - CacheDuration: DefaultCacheDuration, - KeepaliveInterval: DefaultKeepaliveInterval, - ManagerInterval: DefaultManagerInterval, - GlobalTopicLimit: defaultGlobalTopicLimit, - VisitorRequestLimit: defaultVisitorRequestLimit, - VisitorRequestLimitBurst: defaultVisitorRequestLimitBurst, - VisitorSubscriptionLimit: defaultVisitorSubscriptionLimit, - } -} diff --git a/config/config.yml b/config/config.yml deleted file mode 100644 index e4a6fc07..00000000 --- a/config/config.yml +++ /dev/null @@ -1,30 +0,0 @@ -# ntfy config file - -# Listen address for the HTTP web server -# Format: : -# -# listen-http: ":80" - -# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. -# This is optional and only required to support Android apps (which don't allow background services anymore). -# -# firebase-key-file: - -# If set, messages are cached in a local SQLite database instead of only in-memory. This -# allows for service restarts without losing messages in support of the since= parameter. -# -# cache-file: - -# Duration for which messages will be buffered before they are deleted. -# This is required to support the "since=..." and "poll=1" parameter. -# -# cache-duration: 12h - -# Interval in which keepalive messages are sent to the client. This is to prevent -# intermediaries closing the connection for inactivity. -# -# keepalive-interval: 30s - -# Interval in which the manager prunes old messages, deletes topics and prints the stats. -# -# manager-interval: 1m diff --git a/config/ntfy.service b/config/ntfy.service deleted file mode 100644 index 4a70cd02..00000000 --- a/config/ntfy.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=ntfy server -After=network.target - -[Service] -ExecStart=/usr/bin/ntfy -Restart=on-failure - -[Install] -WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d39492e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "2.1" +services: + ntfy: + image: binwiederhier/ntfy + container_name: ntfy + command: + - serve + environment: + - TZ=UTC # optional: Change to your desired timezone + user: UID:GID # optional: Set custom user/group or uid/gid + volumes: + - /var/cache/ntfy:/var/cache/ntfy + - /etc/ntfy:/etc/ntfy + ports: + - 80:80 + restart: unless-stopped + diff --git a/docs/_overrides/main.html b/docs/_overrides/main.html new file mode 100644 index 00000000..52483ebd --- /dev/null +++ b/docs/_overrides/main.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block announce %} + + +If you like ntfy, please consider sponsoring me via GitHub Sponsors +or Liberapay + + +, or subscribing to ntfy Pro. + +{% endblock %} diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 00000000..2662a537 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,1462 @@ +# Configuring the ntfy server +The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`, +see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments +or using environment variables. + +## Quick start +By default, simply running `ntfy serve` will start the server at port 80. No configuration needed. Batteries included 😀. +If everything works as it should, you'll see something like this: +``` +$ ntfy serve +2021/11/30 19:59:08 Listening on :80 +``` + +You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md), +[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure +the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to +get a list of [command line options](#command-line-options). + +## Example config +!!! info + Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. + It contains examples and detailed descriptions of all the settings. + +The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http` +and `listen-https`), and socket path (`listen-unix`). All the other things are additional features. + +Here are a few working sample configs: + +=== "server.yml (HTTP-only, with cache + attachments)" + ``` yaml + base-url: "http://ntfy.example.com" + cache-file: "/var/cache/ntfy/cache.db" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` + +=== "server.yml (HTTP+HTTPS, with cache + attachments)" + ``` yaml + base-url: "http://ntfy.example.com" + listen-http: ":80" + listen-https: ":443" + key-file: "/etc/letsencrypt/live/ntfy.example.com.key" + cert-file: "/etc/letsencrypt/live/ntfy.example.com.crt" + cache-file: "/var/cache/ntfy/cache.db" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` + +=== "server.yml (behind proxy, with cache + attachments)" + ``` yaml + base-url: "http://ntfy.example.com" + listen-http: ":2586" + cache-file: "/var/cache/ntfy/cache.db" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` + +=== "server.yml (ntfy.sh config)" + ``` yaml + # All the things: Behind a proxy, Firebase, cache, attachments, + # SMTP publishing & receiving + + base-url: "https://ntfy.sh" + listen-http: "127.0.0.1:2586" + firebase-key-file: "/etc/ntfy/firebase.json" + cache-file: "/var/cache/ntfy/cache.db" + behind-proxy: true + attachment-cache-dir: "/var/cache/ntfy/attachments" + smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" + smtp-sender-user: "AKIDEADBEEFAFFE12345" + smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." + smtp-sender-from: "ntfy@ntfy.sh" + smtp-server-listen: ":25" + smtp-server-domain: "ntfy.sh" + smtp-server-addr-prefix: "ntfy-" + keepalive-interval: "45s" + ``` + +## Message cache +If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period +of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve +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: + +* `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 +passed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward +the message to the subscribers. + +Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the +[`since=` parameter](subscribe/api.md#fetch-cached-messages). + +## Attachments +If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable +this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). +Once these options are set and the directory is writable by the server user, you can upload attachments via PUT. + +By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues +and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download +feature) to download the file. The following config options are relevant to attachments: + +* `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs +* `attachment-cache-dir` is the cache directory for attached files +* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G) +* `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) +* `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) + +Here's an example config using mostly the defaults (except for the cache directory, which is empty by default): + +=== "/etc/ntfy/server.yml (minimal)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` + +=== "/etc/ntfy/server.yml (all options)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-cache-dir: "/var/cache/ntfy/attachments" + attachment-total-size-limit: "5G" + attachment-file-size-limit: "15M" + attachment-expiry-duration: "3h" + visitor-attachment-total-size-limit: "100M" + visitor-attachment-daily-bandwidth-limit: "500M" + ``` + +Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` +and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. + +## Access control +By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how +ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization. + +ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles +(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). +Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access. + +To set up auth, simply **configure the following two options**: + +* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested + location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) +* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be + set to `read-write` (default), `read-only`, `write-only` or `deny-all`. + +Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command +lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these +commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user +accessing them has the right permissions. + +### Users and roles +The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change +passwords or roles (`user` or `admin`). In practice, you'll often just create one admin +user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)). + +**Roles:** + +* Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access` + (see [below](#access-control-list-acl)). +* Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary. + +**Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details): + +``` +ntfy user list # Shows list of users (alias: 'ntfy access') +ntfy user add phil # Add regular user phil +ntfy user add --role=admin phil # Add admin user phil +ntfy user del phil # Delete user phil +ntfy user change-pass phil # Change password for user phil +ntfy user change-role phil admin # Make user phil an admin +ntfy user change-tier phil pro # Change phil's tier to "pro" +``` + +### Access control list (ACL) +The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**. +Each entry represents the access permissions for a user to a specific topic or topic pattern. + +The ACL can be displayed or modified with the `ntfy access` command: + +``` +ntfy access # Shows access control list (alias: 'ntfy user list') +ntfy access USERNAME # Shows access control entries for USERNAME +ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC +``` + +A `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the +anonymous user `everyone` or `*`, which represents clients that access the API without username/password. + +A `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any +number of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any +number of characters. + +A `PERMISSION` is any of the following supported permissions: + +* `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as + [subscribing](subscribe/api.md) and reading messages +* `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic +* `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it +* `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic + +**Example commands** (type `ntfy access --help` for more details): +``` +ntfy access # Shows entire access control list +ntfy access phil # Shows access for user phil +ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil +ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic +ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..." +ntfy access --reset # Reset entire access control list +ntfy access --reset phil # Reset all access for user phil +ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic +``` + +**Example ACL:** +``` +$ ntfy access +user phil (admin) +- read-write access to all topics (admin role) +user ben (user) +- read-write access to topic garagedoor +- read-write access to topic alerts* +- read-only access to topic furnace +user * (anonymous) +- read-only access to topic announcements +- read-only access to topic server-stats +- no access to any (other) topics (server config) +``` + +In this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary). +User `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access +to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated +(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. + +### Access tokens +In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful +to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may +want to use a dedicated token to publish from your backup host, and one from your home automation system. + +!!! info + As of today, access tokens grant users **full access to the user account**. Aside from changing the password, + and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap, + but not yet implemented. + +The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire +automatically (or never expire). Each user can have up to 20 tokens (hardcoded). + +**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details): +``` +ntfy token list # Shows list of tokens for all users +ntfy token list phil # Shows list of tokens for user phil +ntfy token add phil # Create token for user phil which never expires +ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days +ntfy token remove phil tk_th2sxr... # Delete token +``` + +**Creating an access token:** +``` +$ ntfy token add --expires=30d --label="backups" phil +$ ntfy token list +user phil +- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST +``` + +Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or +subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens). + +### Example: Private instance +The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: + +=== "/etc/ntfy/server.yml" + ``` yaml + auth-file: "/var/lib/ntfy/user.db" + auth-default-access: "deny-all" + ``` + +After that, simply create an `admin` user: + +``` +$ ntfy user add --role=admin phil +password: mypass +confirm: mypass +user phil added with role admin +``` + +Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) +with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example: + +=== "Command line (curl)" + ``` + curl \ + -u phil:mypass \ + -d "Look ma, with auth" \ + https://ntfy.example.com/mysecrets + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -u phil:mypass \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets HTTP/1.1 + Host: ntfy.example.com + Authorization: Basic cGhpbDpteXBhc3M= + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets', { + method: 'POST', // PUT works too + body: 'Look ma, with auth', + headers: { + 'Authorization': 'Basic cGhpbDpteXBhc3M=' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", + strings.NewReader("Look ma, with auth")) + req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=") + http.DefaultClient.Do(req) + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets", + data="Look ma, with auth", + headers={ + "Authorization": "Basic cGhpbDpteXBhc3M=" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + 'Content-Type: text/plain\r\n' . + 'Authorization: Basic cGhpbDpteXBhc3M=', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + +### Example: UnifiedPush +[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …) +has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages. +The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the +**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details. + +To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either +allow anonymous write access for the entire prefix or explicitly per topic: + +=== "Prefix" + ``` + $ ntfy access '*' 'up*' write-only + ``` + +=== "Explicitly" + ``` + $ ntfy access '*' upYzMtZGZiYTY5 write-only + ``` + +## E-mail notifications +To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured, +you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. +`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`). + +As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the +following settings: + +* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer +* `smtp-sender-addr` is the hostname:port of the SMTP server +* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user +* `smtp-sender-from` is the e-mail address of the sender + +Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is +configured for `ntfy.sh`): + +=== "/etc/ntfy/server.yml" + ``` yaml + base-url: "https://ntfy.sh" + smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" + smtp-sender-user: "AKIDEADBEEFAFFE12345" + smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." + smtp-sender-from: "ntfy@ntfy.sh" + ``` + +Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` +and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. + +## E-mail publishing +To allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured, +users can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or +`myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for +statuspage.io (though these days most services also support webhooks and HTTP calls). + +To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`: + +* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` +* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below) +* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance, + only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be + accepted (which may obviously be a spam problem). + +Here's an example config (this is how it is configured for `ntfy.sh`): + +=== "/etc/ntfy/server.yml" + ``` yaml + smtp-server-listen: ":25" + smtp-server-domain: "ntfy.sh" + smtp-server-addr-prefix: "ntfy-" + ``` + +In addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record) +and a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is +configured (in [Amazon Route 53](https://aws.amazon.com/route53/)): + +
+ ![DNS records for incoming mail](static/img/screenshot-email-publishing-dns.png){ width=600 } +
DNS records for incoming mail
+
+ +You can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g. +`email.txt` + +``` +EHLO example.com +MAIL FROM: phil@example.com +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Subject: Email for you +Content-Type: text/plain; charset="UTF-8" + +Hello from 🇩🇪 +. +``` + +And then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the +ntfy server. Read them carefully. + +``` +$ cat email.txt | nc -N ntfy.sh 25 +220 ntfy.sh ESMTP Service Ready +250-Hello example.com +... +250 2.0.0 Roger, accepting mail from +250 2.0.0 I'll make sure gets this +``` + +As for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this: + +``` +$ dig MX ntfy.sh +short +10 mx1.ntfy.sh. +$ dig A mx1.ntfy.sh +short +3.139.215.220 +``` + +### Local-only email +If you want to send emails from an internal service on the same network as your ntfy instance, you do not need to +worry about DNS records at all. Define a port for the SMTP server and pick an SMTP server domain (can be +anything). + +=== "/etc/ntfy/server.yml" + ``` yaml + smtp-server-listen: ":25" + smtp-server-domain: "example.com" + smtp-server-addr-prefix: "ntfy-" # optional + ``` + +Then, in the email settings of your internal service, set the SMTP server address to the IP address of your +ntfy instance. Set the port to the value you defined in `smtp-server-listen`. Leave any username and password +fields empty. In the "From" address, pick anything (e.g., "alerts@ntfy.sh"); the value doesn't matter. +In the "To" address, put in an email address that follows this pattern: `[topic]@[smtp-server-domain]` (or +`[smtp-server-addr-prefix][topic]@[smtp-server-domain]` if you set `smtp-server-addr-prefix`). + +So if you used `example.com` as the SMTP server domain, and you want to send a message to the `email-alerts` +topic, set the "To" address to `email-alerts@example.com`. If the topic has access restrictions, you will need +to include an access token in the "To" address, such as `email-alerts+tk_AbC123dEf456@example.com`. + +If the internal service lets you use define an email "Subject", it will become the title of the notification. +The body of the email will become the message of the notification. + +## Behind a proxy (TLS, etc.) +!!! warning + If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are + [rate limited](#rate-limiting) as if they are one. + +It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates +using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services. +Whatever your reasons may be, there are a few things to consider. + +If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the +[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor, +as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will +be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address. + +=== "/etc/ntfy/server.yml" + ``` yaml + # Tell ntfy to use "X-Forwarded-For" to identify visitors + behind-proxy: true + ``` + +### TLS/SSL +ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you +are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below). + +I highly recommend using [certbot](https://certbot.eff.org/). I use it with the [dns-route53 plugin](https://certbot-dns-route53.readthedocs.io/en/stable/), +which lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challenge. That's much easier than using the +HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to +be incredibly helpful. + +### nginx/Apache2/caddy +For your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets** +by forwarding the `Connection` and `Upgrade` headers accordingly. + +In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic +or the root domain: + +=== "nginx (convenient)" + ``` + # /etc/nginx/sites-*/ntfy + # + # This config allows insecure HTTP POST/PUT requests against topics to allow a short curl syntax (without -L + # and "https://" prefix). It also disables output buffering, which has worked well for the ntfy.sh server. + # + # This is pretty much how ntfy.sh is configured. To see the exact configuration, + # see https://github.com/binwiederhier/ntfy-ansible/ + + server { + listen 80; + server_name ntfy.sh; + + location / { + # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want + # it to work with curl without the annoying https:// prefix + set $redirect_https ""; + if ($request_method = GET) { + set $redirect_https "yes"; + } + if ($request_uri ~* "^/([-_a-z0-9]{0,64}$|docs/|static/)") { + set $redirect_https "${redirect_https}yes"; + } + if ($redirect_https = "yesyes") { + return 302 https://$http_host$request_uri$is_args$query_string; + } + + proxy_pass http://127.0.0.1:2586; + proxy_http_version 1.1; + + proxy_buffering off; + proxy_request_buffering off; + proxy_redirect off; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 3m; + proxy_send_timeout 3m; + proxy_read_timeout 3m; + + client_max_body_size 0; # Stream request body to backend + } + } + + server { + listen 443 ssl http2; + server_name ntfy.sh; + + # See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6see https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6 + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:2586; + proxy_http_version 1.1; + + proxy_buffering off; + proxy_request_buffering off; + proxy_redirect off; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 3m; + proxy_send_timeout 3m; + proxy_read_timeout 3m; + + client_max_body_size 0; # Stream request body to backend + } + } + ``` + +=== "nginx (more secure)" + ``` + # /etc/nginx/sites-*/ntfy + # + # This config requires the use of the -L flag in curl to redirect to HTTPS, and it keeps nginx output buffering + # enabled. While recommended, I have had issues with that in the past. + + server { + listen 80; + server_name ntfy.sh; + + location / { + return 302 https://$http_host$request_uri$is_args$query_string; + + proxy_pass http://127.0.0.1:2586; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 3m; + proxy_send_timeout 3m; + proxy_read_timeout 3m; + + client_max_body_size 0; # Stream request body to backend + } + } + + server { + listen 443 ssl http2; + server_name ntfy.sh; + + # See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6see https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6 + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:2586; + proxy_http_version 1.1; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_connect_timeout 3m; + proxy_send_timeout 3m; + proxy_read_timeout 3m; + + client_max_body_size 0; # Stream request body to backend + } + } + ``` + +=== "Apache2" + ``` + # /etc/apache2/sites-*/ntfy.conf + + + ServerName ntfy.sh + + # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + ProxyPass / http://127.0.0.1:2586/ upgrade=websocket + ProxyPassReverse / http://127.0.0.1:2586/ + + 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 proxy_http") + ProxyPass / http://127.0.0.1:2586/ upgrade=websocket + ProxyPassReverse / http://127.0.0.1:2586/ + + 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. + + ntfy.sh, http://nfty.sh { + reverse_proxy 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 + @httpget { + protocol http + method GET + path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/) + } + redir @httpget https://{host}{uri} + } + ``` + +## Firebase (FCM) +!!! info + Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app). + For a self-hosted instance, it's easier to just not bother with FCM. + +[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) is the Google approved way to send +push messages to Android devices. FCM is the only method that an Android app can receive messages without having to run a +[foreground service](https://developer.android.com/guide/components/foreground-services). + +For the main host [ntfy.sh](https://ntfy.sh), the [ntfy Android app](subscribe/phone.md) uses Firebase to send messages +to the device. For other hosts, instant delivery is used and FCM is not involved. + +To configure FCM for your self-hosted instance of the ntfy server, follow these steps: + +1. Sign up for a [Firebase account](https://console.firebase.google.com/) +2. Create a Firebase app and download the key file (e.g. `myapp-firebase-adminsdk-...json`) +3. Place the key file in `/etc/ntfy`, set the `firebase-key-file` in `server.yml` accordingly and restart the ntfy server +4. Build your own Android .apk following [these instructions](develop.md#android-app) + +Example: +``` +# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. +# This is optional and only required to support Android apps (which don't allow background services anymore). +# +firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" +``` + +## iOS instant notifications +Unlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant +push notifications without a central server. + +To still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request` +messages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app), +which will then forward it to Firebase/APNS. + +To configure it, simply set `upstream-base-url` like so: + +``` yaml +upstream-base-url: "https://ntfy.sh" +upstream-access-token: "..." # optional, only if rate limits exceeded, or upstream server protected +``` + +If set, all incoming messages will publish a poll request to the configured upstream server, containing +the message ID of the original message, instructing the iOS app to poll this server for the actual message contents. + +If `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours, +depending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though. + +In case you're curious, here's an example of the entire flow: + +- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic` +- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL) +- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a + poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server + contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic). +- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device +- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it + +Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl: + +``` +curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b +{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"} +``` + +Note that the self-hosted server literally sends the message `New message` for every message, even if your message +may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all), +it'll show `New message` as a popup. + +## Web Push +[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030)) +allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed. +When enabled, the user can enable **background notifications** for their topics in the wep app under Settings. Once enabled by the +user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then +forward it to the browser. + +To configure Web Push, you need to generate and configure a [VAPID](https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid) keypair (via `ntfy webpush keys`), +a database to keep track of the browser's subscriptions, and an admin email address (you): + +- `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-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` + +Limitations: + +- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_ + certificate is required, as service workers will not run on origins with untrusted certificates. + +- Web Push is only supported for the same server. You cannot use subscribe to web push on a topic on another server. This + is due to a limitation of the Push API, which doesn't allow multiple push servers for the same origin. + +To configure VAPID keys, first generate them: + +```sh +$ ntfy webpush keys +Web Push keys generated. +... +``` + +Then copy the generated values into your `server.yml` or use the corresponding environment variables or command line arguments: + +```yaml +web-push-public-key: AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 +web-push-private-key: AA2BB1234567890abcdefzxcvbnm1234567890 +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 7 days, +and will automatically expire after 9 days (not configurable). 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. + +Changing your public/private keypair is **not recommended**. Browsers only allow one server identity (public key) per origin, and +if you change them the clients will not be able to subscribe via web push until the user manually clears the notification permission. + +## Tiers +ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as +daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments), +tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way +to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)). + +By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file. +Once a user is associated with a tier, some limits are overridden based on the tier. + +The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers. + +**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details): +``` +ntfy tier add pro # Add tier with code "pro", using the defaults +ntfy tier change --name="Pro" pro # Update the name of an existing tier +ntfy tier del starter # Delete an existing tier +ntfy user change-tier phil pro # Switch user "phil" to tier "pro" +``` + +**Creating a tier (full example):** +``` +ntfy tier add \ + --name="Pro" \ + --message-limit=10000 \ + --message-expiry-duration=24h \ + --email-limit=50 \ + --call-limit=10 \ + --reservation-limit=10 \ + --attachment-file-size-limit=100M \ + --attachment-total-size-limit=1G \ + --attachment-expiry-duration=12h \ + --attachment-bandwidth-limit=5G \ + --stripe-price-id=price_123456 \ + pro +``` + +## Payments +ntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled, +users can register, login and switch plans in the web app. The web app will behave slightly differently if payments +are enabled (e.g. showing an upgrade banner, or "ntfy Pro" tags). + +!!! info + The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use + cases. + +To enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key` +config options: + +* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values + enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys). +* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe. + Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks). +* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach + out with billing questions. If unset, nothing will be displayed. + +In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks) +for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points +to `https://ntfy.example.com/v1/account/billing/webhook`. + +Here's an example: + +``` yaml +stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG" +stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR" +billing-contact: "phil@example.com" +``` + +## Phone calls +ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled, +users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header. +See [publishing page](publish.md#phone-calls) for more details. + +To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers +are the easiest), and then configure the following options: + +* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 +* `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 + +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. + +## Rate limiting +!!! info + Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. + Otherwise, all visitors are rate limited as if they are one. + +By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload. +There are various limits and rate limits in place that you can use to configure the server: + +* **Global limit**: A global limit applies across all visitors (IPs, clients, users) +* **Visitor limit**: A visitor limit only applies to a certain visitor. A **visitor** is identified by its IP address + (or the `X-Forwarded-For` header if `behind-proxy` is set). All config options that start with the word `visitor` apply + only on a per-visitor basis. + +During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails +(e.g. when you reconnect after a connection drop), it shouldn't have any effect. + +### General limits +Let's do the easy limits first: + +* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000. +* `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30. + +### Request limits +In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests. +This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)): + +Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`). +After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one +request every 5s (defined by `visitor-request-limit-replenish`) + +* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60. +* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s. +* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate + limiting; hostnames are resolved at the time the server is started. Defaults to an empty list. + +### Message limits +By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits). +For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests +to publish messages, then that is the daily message limit. + +To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number +of messages a visitor can send in a day. This counter is reset every day at midnight (UTC). + +### Attachment limits +Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant +per-visitor limits: + +* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M. + The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`, + see [publishing docs](publish.md#attachments)) do not count here. +* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor, + including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in + most cloud providers. This defaults to 500M. + +### E-mail limits +Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) +are enabled): + +* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. +* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. + +### Firebase limits +If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no` +is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling) +on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time +of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to +the same topic**. + +In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message +is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously, +there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected. +After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor. + +If this ever happens, there will be a log message that looks something like this: +``` +WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor +``` + +### Subscriber-based rate limiting +By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment +size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits +of a topic's subscriber, instead of the limits of the publisher.** + +If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed +to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume +publishers (e.g. Matrix/Mastodon servers) are allowed to send. + +Once enabled, a client may send a `Rate-Topics: ,,...` header when subscribing to topics via +HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits +to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic. + +UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage` +response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's +`visitor-message-daily-limit`. + +To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`. + +## Tuning for scale +If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, +if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. +This limit is typically called `nofile`. Other than that, RAM and CPU are obviously relevant. You may also want to check +out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/how_many_actively_connected_http_clients_can_a_go/). + +Depending on *how you run it*, here are a few limits that are relevant: + +### Message cache +By default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it +syncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh, +the [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted. +See [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details. + +In addition to that, for very high load servers (such as ntfy.sh), it may be beneficial to write messages to the cache +in batches, and asynchronously. This can be enabled with the `cache-batch-size` and `cache-batch-timeout`. If you start +seeing `database locked` messages in the logs, you should probably enable that. + +Here's how ntfy.sh has been tuned in the `server.yml` file: + +``` yaml +cache-batch-size: 25 +cache-batch-timeout: "1s" +cache-startup-queries: | + pragma journal_mode = WAL; + pragma synchronous = normal; + pragma temp_store = memory; + pragma busy_timeout = 15000; + vacuum; +``` + +### For systemd services +If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the +`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it +by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf` +is not relevant. + +=== "/etc/systemd/system/ntfy.service.d/override.conf" + ``` + # Allow 20,000 ntfy connections (and give room for other file handles) + [Service] + LimitNOFILE=20500 + ``` + +### Outside of systemd +If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to +increase the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting +by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`. + +=== "/etc/security/limits.conf" + ``` + # Increase open files limit globally + * hard nofile 20500 + ``` + +### Proxy limits (nginx, Apache2) +If you are running [behind a proxy](#behind-a-proxy-tls-etc) (e.g. nginx, Apache), the open files limit of the proxy is also +relevant. So if your proxy runs inside of systemd, increase the limits in systemd for the proxy. Typically, the proxy +open files limit has to be **double the number of how many connections you'd like to support**, because the proxy has +to maintain the client connection and the connection to ntfy. + +=== "/etc/nginx/nginx.conf" + ``` + events { + # Allow 40,000 proxy connections (2x of the desired ntfy connection count; + # and give room for other file handles) + worker_connections 40500; + } + ``` + +=== "/etc/systemd/system/nginx.service.d/override.conf" + ``` + # Allow 40,000 proxy connections (2x of the desired ntfy connection count; + # and give room for other file handles) + [Service] + LimitNOFILE=40500 + ``` + +### Banning bad actors (fail2ban) +If you put stuff on the Internet, bad actors will try to break them or break in. [fail2ban](https://www.fail2ban.org/) +and nginx's [ngx_http_limit_req_module module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html) can be used +to ban client IPs if they misbehave. This is on top of the [rate limiting](#rate-limiting) inside the ntfy server. + +Here's an example for how ntfy.sh is configured, following the instructions from two tutorials ([here](https://easyengine.io/tutorials/nginx/fail2ban/) +and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-attack/)): + +=== "/etc/nginx/nginx.conf" + ``` + # Rate limit all IP addresses + http { + limit_req_zone $binary_remote_addr zone=one:10m rate=45r/m; + } + + # Alternatively, whitelist certain IP addresses + http { + geo $limited { + default 1; + 116.203.112.46/32 0; + 132.226.42.65/32 0; + ... + } + map $limited $limitkey { + 1 $binary_remote_addr; + 0 ""; + } + limit_req_zone $limitkey zone=one:10m rate=45r/m; + } + ``` + +=== "/etc/nginx/sites-enabled/ntfy.sh" + ``` + # For each server/location block + server { + location / { + limit_req zone=one burst=1000 nodelay; + } + } + ``` + +=== "/etc/fail2ban/filter.d/nginx-req-limit.conf" + ``` + [Definition] + failregex = limiting requests, excess:.* by zone.*client: + ignoreregex = + ``` + +=== "/etc/fail2ban/jail.local" + ``` + [nginx-req-limit] + enabled = true + filter = nginx-req-limit + action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp] + logpath = /var/log/nginx/error.log + findtime = 600 + bantime = 14400 + maxretry = 10 + ``` + +## Health checks +A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below. +If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy. + +```json +{"healthy":true} +``` + +See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment. + +## Monitoring +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 +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. + +- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) +- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly + enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" + +=== "server.yml (Using default port)" + ```yaml + enable-metrics: true + ``` + +=== "server.yml (Using dedicated IP/port)" + ```yaml + metrics-listen-http: "10.0.1.1:9090" + ``` + +In Prometheus, an example scrape config would look like this: + +=== "prometheus.yml" + ```yaml + scrape_configs: + - job_name: "ntfy" + static_configs: + - targets: ["10.0.1.1:9090"] + ``` + +Here's an example Grafana dashboard built from the metrics (see [Grafana JSON on GitHub](https://raw.githubusercontent.com/binwiederhier/ntfy/main/examples/grafana-dashboard/ntfy-grafana.json)): + +
+ +
ntfy Grafana dashboard
+
+ +## Profiling +ntfy can expose Go's [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoints to support profiling of the ntfy server. +If enabled, ntfy will listen on a dedicated listen IP/port, which can be accessed via the web browser on `http://:/debug/pprof/`. +This can be helpful to expose bottlenecks, and visualize call flows. To enable, simply set the `profile-listen-http` config option. + +## Logging & debugging +By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format. + +ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular +log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded +by calling `kill -HUP $pid` or `systemctl reload ntfy`. + +The following config options define the logging behavior: + +* `log-format` defines the output format, can be `text` (default) or `json` +* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr. +* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`. + Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes. +* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful + for debugging certain parts of the system (e.g. only the account management, or only a certain visitor). + This is an array of strings in the format: + - `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace` + - `field -> level` to match any value, e.g. `time_taken_ms -> debug` + +**Logging config (good for production use):** +``` yaml +log-level: info +log-format: json +log-file: /var/log/ntfy.log +``` + +**Temporary debugging:** +If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level` +to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message +contents. The `trace` setting will also print the message contents. + +Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`), +a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what +they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy). + +Here's an example that will output only `info` log events, except when they match either of the defined overrides: +``` yaml +log-level: info +log-level-overrides: + - "tag=manager -> trace" + - "visitor_ip=1.2.3.4 -> debug" + - "time_taken_ms -> debug" +``` + +!!! warning + The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a + performance penalty. Only use it for temporary debugging. + +You can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after +editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), +or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this: + +``` +$ ntfy serve +2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO +2022/06/02 10:29:34 INFO Partially hot reloading configuration ... +2022/06/02 10:29:34 INFO Log level is TRACE +``` + +## Config options +Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a +CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment +variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). + +!!! info + All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g. + `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](#wal-for-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, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | +| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | +| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | +| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | +| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | +| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | +| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | +| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | +| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | +| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | +| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | +| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *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. | +| `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 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 | +| `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) | +| `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 | + +The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. +The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. + +## Command line options +``` +NAME: + ntfy serve - Run the ntfy server + +USAGE: + ntfy serve [OPTIONS..] + +CATEGORY: + Server commands + +DESCRIPTION: + Run the ntfy server and listen for incoming requests + + The command will load the configuration from /etc/ntfy/server.yml. Config options can + be overridden using the command line options. + + Examples: + ntfy serve # Starts server in the foreground (on port 80) + ntfy serve --listen-http :8080 # Starts server with alternate port + +OPTIONS: + --debug, -d enable debug logging (default: false) [$NTFY_DEBUG] + --trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE] + --no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES] + --log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL] + --log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES] + --log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT] + --log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE] + --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] + --base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] + --listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] + --listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS] + --listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX] + --listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE] + --key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] + --cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] + --firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] + --cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] + --cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] + --cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE] + --cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT] + --cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES] + --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] + --attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] + --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] + --attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] + --keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] + --manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] + --disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS] + --web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT] + --enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP] + --enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN] + --enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS] + --upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL] + --upstream-access-token value, --upstream_access_token value access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth [$NTFY_UPSTREAM_ACCESS_TOKEN] + --smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] + --smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] + --smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] + --smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] + --smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] + --smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] + --smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] + --twilio-account value, --twilio_account value Twilio account SID, used for phone calls, e.g. AC123... [$NTFY_TWILIO_ACCOUNT] + --twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN] + --twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER] + --twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE] + --global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] + --visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] + --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] + --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] + --visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] + --visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] + --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] + --visitor-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: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] + --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING] + --behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] + --stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY] + --stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY] + --billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT] + --enable-metrics, --enable_metrics if set, Prometheus metrics are exposed via the /metrics endpoint (default: false) [$NTFY_ENABLE_METRICS] + --metrics-listen-http value, --metrics_listen_http value ip:port used to expose the metrics endpoint (implicitly enables metrics) [$NTFY_METRICS_LISTEN_HTTP] + --profile-listen-http value, --profile_listen_http value ip:port used to expose the profiling endpoints (implicitly enables profiling) [$NTFY_PROFILE_LISTEN_HTTP] + --web-push-public-key value, --web_push_public_key value public key used for web push notifications [$NTFY_WEB_PUSH_PUBLIC_KEY] + --web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY] + --web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE] + --web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS] + --web-push-startup-queries value, --web_push_startup-queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES] + --help, -h show help +``` diff --git a/docs/deprecations.md b/docs/deprecations.md new file mode 100644 index 00000000..99cdeeb9 --- /dev/null +++ b/docs/deprecations.md @@ -0,0 +1,60 @@ +# Deprecation notices +This page is used to list deprecation notices for ntfy. Deprecated commands and options will be +**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated +before the behavior is changed depends on the severity of the change, and how prominent the feature is. + +## Active deprecations +_No active deprecations_ + +## Previous deprecations + +### ntfy CLI: `ntfy publish --env-topic` will be removed +> Active since 2022-06-20, behavior changed with v1.30.1 + +The `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the +`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag. + +=== "Before" + ``` + $ NTFY_TOPIC=mytopic ntfy publish --env-topic "this is the message" + ``` + +=== "After" + ``` + $ NTFY_TOPIC=mytopic ntfy publish "this is the message" + ``` + +### Android app: WebSockets will become the default connection protocol +> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20) + +Instant delivery connections and connections to self-hosted servers in the Android app were going to switch +to use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default +and add a notice banner in the Android app instead. + +### Android app: Using `since=` instead of `since=` +> Active since 2022-02-27, behavior changed with v1.14.0 + +The Android app started using `since=` instead of `since=`, which means as of Android app v1.14.0, +it will not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app. + +The `since=` endpoint will continue to work. This is merely a notice that the Android app behavior will change. + +### Running server via `ntfy` (instead of `ntfy serve`) +> Deprecated 2021-12-17, behavior changed with v1.10.0 + +As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical +anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than +just the server. + +=== "Before" + ``` + $ ntfy + 2021/12/17 08:16:01 Listening on :80/http + ``` + +=== "After" + ``` + $ ntfy serve + 2021/12/17 08:16:01 Listening on :80/http + ``` + diff --git a/docs/develop.md b/docs/develop.md new file mode 100644 index 00000000..b090c8c5 --- /dev/null +++ b/docs/develop.md @@ -0,0 +1,446 @@ +# Development +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)**. + +## ntfy server +The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the +server consists of three components: + +* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at + [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is + in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a + [SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires + [Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below). +* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), + which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to + build the docs. +* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/) + to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`) + and install all the 100,000 dependencies (*sigh*). + +All of these components are built and then **baked into one binary**. + +### Navigating the code +Code: + +* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client +* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish` +* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic +* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml` +* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json` + +Build related: + +* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building +* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/)) +* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file +* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/)) +* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm) + + +The `web/` and `docs/` folder are the sources for web app and documentation. During the build process, +the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation). + +### Build/test on Gitpod +To get a quick working development environment you can use [Gitpod](https://gitpod.io), an in-browser IDE +that makes it easy to develop ntfy without having to set up a desktop IDE. For any real development, +I do suggest a proper IDE like [IntelliJ IDEA](https://www.jetbrains.com/idea/). + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/binwiederhier/ntfy) + +### Build requirements + +* [Go](https://go.dev/) (required for main server) +* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings) +* [Make](https://www.gnu.org/software/make/) (required for convenience) +* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings) +* [GoReleaser](https://goreleaser.com/) (required for a proper main server build) +* [Python](https://www.python.org/) (for `pip`, only to build the docs) +* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app) + +### Install dependencies +These steps **assume Ubuntu**. Steps may vary on different Linux distributions. + +First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)): +``` shell +wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz +sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz +export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin +go version # verifies that it worked +``` + +Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)): +``` shell +go install github.com/goreleaser/goreleaser@latest +goreleaser -v # verifies that it worked +``` + +Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)): +``` shell +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs +npm -v # verifies that it worked +``` + +Then install a few other things required: +``` shell +sudo apt install \ + build-essential \ + libsqlite3-dev \ + gcc-arm-linux-gnueabi \ + gcc-aarch64-linux-gnu \ + python3-pip \ + git +``` + +### Check out code +Now check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy): + +=== "via HTTPS" + ``` shell + git clone https://github.com/binwiederhier/ntfy.git + cd ntfy + ``` + +=== "via SSH" + ``` shell + git clone git@github.com:binwiederhier/ntfy.git + cd ntfy + ``` + +### Build all the things +Now you can finally build everything. There are tons of `make` targets, so maybe just review what's there first +by typing `make`: + +``` shell +$ make +Typical commands (more see below): + make build - Build web app, documentation and server/client (sloowwww) + make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs) + make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64) + make web - Build the web app + make docs - Build the documentation + make check - Run all tests, vetting/formatting checks and linters +... +``` + +If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64), +you can simply run `make build`: + +``` shell +$ make build +... +# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64). +# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets? +``` + +You'll see all the outputs in the `dist/` folder afterwards: + +``` bash +$ find dist +dist +dist/metadata.json +dist/ntfy_arm64_linux_arm64 +dist/ntfy_arm64_linux_arm64/ntfy +dist/ntfy_armv7_linux_arm_7 +dist/ntfy_armv7_linux_arm_7/ntfy +dist/ntfy_amd64_linux_amd64 +dist/ntfy_amd64_linux_amd64/ntfy +dist/config.yaml +dist/artifacts.json +``` + +If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can +use the `make release-snapshot` target: + +``` shell +$ make release-snapshot +... +# This will be REALLY SLOW (sometimes 5+ minutes on my laptop) +``` + +During development, you may want to be more picky and build only certain things. Here are a few examples. + +### Build a Docker image only for Linux + +This is useful to test the final build with web app, docs, and server without any dependencies locally + +``` shell +$ make docker-dev +$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve +``` + +### Build the ntfy binary +To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets: + +``` shell +$ make +Build server & client (using GoReleaser, not release version): + make cli - Build server & client (all architectures) + make cli-linux-amd64 - Build server & client (Linux, amd64 only) + make cli-linux-armv6 - Build server & client (Linux, armv6 only) + make cli-linux-armv7 - Build server & client (Linux, armv7 only) + make cli-linux-arm64 - Build server & client (Linux, arm64 only) + make cli-windows-amd64 - Build client (Windows, amd64 only) + make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary) +``` + +So if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern +system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-linux-amd64` so I can run the binary +right away: + +``` shell +$ make cli-linux-amd64 install-linux-amd64 +$ ntfy serve +``` + +**During development of the main app, you can also just use `go run main.go`**, as long as you run +`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`: + +``` shell +$ export CGO_ENABLED=1 +$ make cli-deps-static-sites +$ go run main.go serve +2022/03/18 08:43:55 Listening on :2586[http] +... +``` + +If you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*: +``` +$ go run main.go serve +server/server.go:85:13: pattern docs: no matching files found +``` + +This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be +present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites` +target creates dummy files that ensure that you'll be able to build. + +While not officially supported (or released), you can build and run the server **on macOS** as well. Simply run +`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it. + +### Build the web app +The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app +is really simple. Just type `make web` and you're in business: + +``` shell +$ make web +... +``` + +This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so +that when you `make cli` (or `make cli-linux-amd64`, ...), you will have the web app included in the `ntfy` binary. + +If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser +at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser +will automatically refresh: + +``` shell +$ cd web +$ npm start +``` + +### Testing Web Push locally + +Reference: + +#### With the dev servers + +1. Get web push keys `go run main.go webpush keys` + +2. Run the server with web push enabled + + ```sh + go run main.go \ + --log-level debug \ + serve \ + --web-push-public-key KEY \ + --web-push-private-key KEY \ + --web-push-email-address \ + --web-push-file=/tmp/webpush.db + ``` + +3. In `web/public/config.js`: + + - Set `base_url` to `http://localhost`, This is required as web push can only be used with the server matching the `base_url`. + + - Set the `web_push_public_key` correctly. + +4. Run `npm run start` + +#### With a built package + +1. Run `make web-build` + +2. Run the server (step 2 above) + +3. Open +### Build the docs +The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the +documentation. As long as you have `mkdocs` installed (see above), this should work fine: + +``` shell +$ make docs +... +``` + +If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation, +serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files: + +``` +$ mkdocs serve +INFO - Building documentation... +INFO - Cleaning site directory +INFO - Documentation built in 5.53 seconds +INFO - [16:28:14] Serving on http://127.0.0.1:8000/ +``` + +Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update. + +## Android app +The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android). +The Android app has two flavors: + +* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account +* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies + +### Navigating the code +* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code +* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code +* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs +* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file + +### IDE/Environment +You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/) +with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀 + +### Check out the code +First check out the repository: + +=== "via HTTPS" + ``` shell + git clone https://github.com/binwiederhier/ntfy-android.git + cd ntfy-android + ``` + +=== "via SSH" + ``` shell + git clone git@github.com:binwiederhier/ntfy-android.git + cd ntfy-android + ``` + +Then either follow the steps for building with or without Firebase. + +### Build F-Droid flavor (no FCM) +!!! info + I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will + work without issues. Please give me feedback if it does/doesn't work for you. + +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 + +# To build a bundle .aab (app/fdroid/release/*.aab) +./gradlew bundleFdroidRelease +``` + +### 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 + work without issues. Please give me feedback if it does/doesn't work for you. + +To build your own version with Firebase, you must: + +* Create a Firebase/FCM account +* Place your account file at `app/google-services.json` +* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) +* Then run: +``` +# To build an unsigned .apk (app/build/outputs/apk/play/*.apk) +./gradlew assemblePlayRelease + +# To build a bundle .aab (app/play/release/*.aab) +./gradlew bundlePlayRelease +``` + +## iOS app +Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are +strictly based off of my development on this app. There may be other versions of macOS / XCode that work. + +### Requirements +1. macOS Monterey or later +1. XCode 13.2+ +1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator) +1. Firebase account +1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license) + +### Apple setup + +!!! info + Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required + for these changes to take effect in the iOS app. + +1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add) + 1. Select "Apple Push Notifications service (APNs)" +1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**) +1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page +1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer. + +!!! warning + If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs. + +If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered +instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application +sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours, +days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly +recommended. + +### Firebase setup + +1. If you haven't already, create a Google / Firebase account +1. Visit the [Firebase console](https://console.firebase.google.com) +1. Create a new Firebase project: + 1. Enter a project name + 1. Disable Google Analytics (currently iOS app does not support analytics) +1. On the "Project settings" page, add an iOS app + 1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value) + 1. Register the app + 1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode) +1. Generate a new service account private key for the ntfy server + 1. Go to "Project settings" > "Service accounts" + 1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server + +### ntfy server +Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these +steps: + +1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder +1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/` +1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key +1. Install go: `brew install go` +1. In the ntfy repository, run `make cli-darwin-server`. + +### XCode setup + +1. Follow step 4 of [Add Firebase to your Apple project](https://firebase.google.com/docs/ios/setup) to install the + `firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging +1. Similarly, install the SQLite.swift package dependency in XCode +1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators + +### PLIST config +To have instant notifications/better notification delivery when using firebase, you will need to add the +`GoogleService-Info.plist` file to your project. Here's how to do that: + +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" + +After that, you should be all set! diff --git a/docs/emojis.md b/docs/emojis.md new file mode 100644 index 00000000..d801ae09 --- /dev/null +++ b/docs/emojis.md @@ -0,0 +1,1831 @@ +# Emoji reference + + + +You can [tag messages](publish.md#tags-emojis) with emojis 🥳 🎉 and other relevant strings. Matching tags are automatically +converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the +[tagging and emojis page](publish.md#tags-emojis). + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
grinning😀
smiley😃
smile😄
grin😁
laughing😆
sweat_smile😅
rofl🤣
joy😂
slightly_smiling_face🙂
upside_down_face🙃
wink😉
blush😊
innocent😇
smiling_face_with_three_hearts🥰
heart_eyes😍
star_struck🤩
kissing_heart😘
kissing😗
relaxed☺️
kissing_closed_eyes😚
kissing_smiling_eyes😙
smiling_face_with_tear🥲
yum😋
stuck_out_tongue😛
stuck_out_tongue_winking_eye😜
zany_face🤪
stuck_out_tongue_closed_eyes😝
money_mouth_face🤑
hugs🤗
hand_over_mouth🤭
shushing_face🤫
thinking🤔
zipper_mouth_face🤐
raised_eyebrow🤨
neutral_face😐
expressionless😑
no_mouth😶
face_in_clouds😶‍🌫️
smirk😏
unamused😒
roll_eyes🙄
grimacing😬
face_exhaling😮‍💨
lying_face🤥
relieved😌
pensive😔
sleepy😪
drooling_face🤤
sleeping😴
mask😷
face_with_thermometer🤒
face_with_head_bandage🤕
nauseated_face🤢
vomiting_face🤮
sneezing_face🤧
hot_face🥵
cold_face🥶
woozy_face🥴
dizzy_face😵
face_with_spiral_eyes😵‍💫
exploding_head🤯
cowboy_hat_face🤠
partying_face🥳
disguised_face🥸
sunglasses😎
nerd_face🤓
monocle_face🧐
confused😕
worried😟
slightly_frowning_face🙁
frowning_face☹️
open_mouth😮
hushed😯
astonished😲
flushed😳
pleading_face🥺
frowning😦
anguished😧
fearful😨
cold_sweat😰
disappointed_relieved😥
cry😢
sob😭
scream😱
confounded😖
persevere😣
disappointed😞
sweat😓
weary😩
tired_face😫
yawning_face🥱
triumph😤
rage😡
angry😠
cursing_face🤬
smiling_imp😈
imp👿
skull💀
skull_and_crossbones☠️
hankey💩
clown_face🤡
japanese_ogre👹
japanese_goblin👺
ghost👻
alien👽
space_invader👾
robot🤖
smiley_cat😺
smile_cat😸
joy_cat😹
heart_eyes_cat😻
smirk_cat😼
kissing_cat😽
scream_cat🙀
crying_cat_face😿
pouting_cat😾
see_no_evil🙈
hear_no_evil🙉
speak_no_evil🙊
kiss💋
love_letter💌
cupid💘
gift_heart💝
sparkling_heart💖
heartpulse💗
heartbeat💓
revolving_hearts💞
two_hearts💕
heart_decoration💟
heavy_heart_exclamation❣️
broken_heart💔
heart_on_fire❤️‍🔥
mending_heart❤️‍🩹
heart❤️
orange_heart🧡
yellow_heart💛
green_heart💚
blue_heart💙
purple_heart💜
brown_heart🤎
black_heart🖤
white_heart🤍
100💯
anger💢
boom💥
dizzy💫
sweat_drops💦
dash💨
hole🕳️
bomb💣
speech_balloon💬
eye_speech_bubble👁️‍🗨️
left_speech_bubble🗨️
right_anger_bubble🗯️
thought_balloon💭
zzz💤
wave👋
raised_back_of_hand🤚
raised_hand_with_fingers_splayed🖐️
hand
vulcan_salute🖖
ok_hand👌
pinched_fingers🤌
pinching_hand🤏
v✌️
crossed_fingers🤞
love_you_gesture🤟
metal🤘
call_me_hand🤙
point_left👈
point_right👉
point_up_2👆
middle_finger🖕
point_down👇
point_up☝️
+1👍
-1👎
fist_raised
fist_oncoming👊
fist_left🤛
fist_right🤜
clap👏
raised_hands🙌
open_hands👐
palms_up_together🤲
handshake🤝
pray🙏
writing_hand✍️
nail_care💅
selfie🤳
muscle💪
mechanical_arm🦾
mechanical_leg🦿
leg🦵
foot🦶
ear👂
ear_with_hearing_aid🦻
nose👃
brain🧠
anatomical_heart🫀
lungs🫁
tooth🦷
bone🦴
eyes👀
eye👁️
tongue👅
lips👄
baby👶
child🧒
boy👦
girl👧
adult🧑
blond_haired_person👱
man👨
bearded_person🧔
man_beard🧔‍♂️
woman_beard🧔‍♀️
red_haired_man👨‍🦰
curly_haired_man👨‍🦱
white_haired_man👨‍🦳
bald_man👨‍🦲
woman👩
red_haired_woman👩‍🦰
person_red_hair🧑‍🦰
curly_haired_woman👩‍🦱
person_curly_hair🧑‍🦱
white_haired_woman👩‍🦳
person_white_hair🧑‍🦳
bald_woman👩‍🦲
person_bald🧑‍🦲
blond_haired_woman👱‍♀️
blond_haired_man👱‍♂️
older_adult🧓
older_man👴
older_woman👵
frowning_person🙍
frowning_man🙍‍♂️
frowning_woman🙍‍♀️
pouting_face🙎
pouting_man🙎‍♂️
pouting_woman🙎‍♀️
no_good🙅
no_good_man🙅‍♂️
no_good_woman🙅‍♀️
ok_person🙆
ok_man🙆‍♂️
ok_woman🙆‍♀️
tipping_hand_person💁
tipping_hand_man💁‍♂️
tipping_hand_woman💁‍♀️
raising_hand🙋
raising_hand_man🙋‍♂️
raising_hand_woman🙋‍♀️
deaf_person🧏
deaf_man🧏‍♂️
deaf_woman🧏‍♀️
bow🙇
bowing_man🙇‍♂️
bowing_woman🙇‍♀️
facepalm🤦
man_facepalming🤦‍♂️
woman_facepalming🤦‍♀️
shrug🤷
man_shrugging🤷‍♂️
woman_shrugging🤷‍♀️
health_worker🧑‍⚕️
man_health_worker👨‍⚕️
woman_health_worker👩‍⚕️
student🧑‍🎓
man_student👨‍🎓
woman_student👩‍🎓
teacher🧑‍🏫
man_teacher👨‍🏫
woman_teacher👩‍🏫
judge🧑‍⚖️
man_judge👨‍⚖️
woman_judge👩‍⚖️
farmer🧑‍🌾
man_farmer👨‍🌾
woman_farmer👩‍🌾
cook🧑‍🍳
man_cook👨‍🍳
woman_cook👩‍🍳
mechanic🧑‍🔧
man_mechanic👨‍🔧
woman_mechanic👩‍🔧
factory_worker🧑‍🏭
man_factory_worker👨‍🏭
woman_factory_worker👩‍🏭
office_worker🧑‍💼
man_office_worker👨‍💼
woman_office_worker👩‍💼
scientist🧑‍🔬
man_scientist👨‍🔬
woman_scientist👩‍🔬
technologist🧑‍💻
man_technologist👨‍💻
woman_technologist👩‍💻
singer🧑‍🎤
man_singer👨‍🎤
woman_singer👩‍🎤
artist🧑‍🎨
man_artist👨‍🎨
woman_artist👩‍🎨
pilot🧑‍✈️
man_pilot👨‍✈️
woman_pilot👩‍✈️
astronaut🧑‍🚀
man_astronaut👨‍🚀
woman_astronaut👩‍🚀
firefighter🧑‍🚒
man_firefighter👨‍🚒
woman_firefighter👩‍🚒
police_officer👮
policeman👮‍♂️
policewoman👮‍♀️
detective🕵️
male_detective🕵️‍♂️
female_detective🕵️‍♀️
guard💂
guardsman💂‍♂️
guardswoman💂‍♀️
ninja🥷
construction_worker👷
construction_worker_man👷‍♂️
construction_worker_woman👷‍♀️
prince🤴
princess👸
person_with_turban👳
man_with_turban👳‍♂️
woman_with_turban👳‍♀️
man_with_gua_pi_mao👲
woman_with_headscarf🧕
person_in_tuxedo🤵
man_in_tuxedo🤵‍♂️
woman_in_tuxedo🤵‍♀️
person_with_veil👰
man_with_veil👰‍♂️
woman_with_veil👰‍♀️
pregnant_woman🤰
breast_feeding🤱
woman_feeding_baby👩‍🍼
man_feeding_baby👨‍🍼
person_feeding_baby🧑‍🍼
angel👼
santa🎅
mrs_claus🤶
mx_claus🧑‍🎄
superhero🦸
superhero_man🦸‍♂️
superhero_woman🦸‍♀️
supervillain🦹
supervillain_man🦹‍♂️
supervillain_woman🦹‍♀️
mage🧙
mage_man🧙‍♂️
mage_woman🧙‍♀️
fairy🧚
fairy_man🧚‍♂️
fairy_woman🧚‍♀️
vampire🧛
vampire_man🧛‍♂️
vampire_woman🧛‍♀️
merperson🧜
merman🧜‍♂️
mermaid🧜‍♀️
elf🧝
elf_man🧝‍♂️
elf_woman🧝‍♀️
genie🧞
genie_man🧞‍♂️
genie_woman🧞‍♀️
zombie🧟
zombie_man🧟‍♂️
zombie_woman🧟‍♀️
massage💆
massage_man💆‍♂️
massage_woman💆‍♀️
haircut💇
haircut_man💇‍♂️
haircut_woman💇‍♀️
walking🚶
walking_man🚶‍♂️
walking_woman🚶‍♀️
standing_person🧍
standing_man🧍‍♂️
standing_woman🧍‍♀️
kneeling_person🧎
kneeling_man🧎‍♂️
kneeling_woman🧎‍♀️
person_with_probing_cane🧑‍🦯
man_with_probing_cane👨‍🦯
woman_with_probing_cane👩‍🦯
person_in_motorized_wheelchair🧑‍🦼
man_in_motorized_wheelchair👨‍🦼
woman_in_motorized_wheelchair👩‍🦼
person_in_manual_wheelchair🧑‍🦽
man_in_manual_wheelchair👨‍🦽
woman_in_manual_wheelchair👩‍🦽
runner🏃
running_man🏃‍♂️
running_woman🏃‍♀️
woman_dancing💃
man_dancing🕺
business_suit_levitating🕴️
dancers👯
dancing_men👯‍♂️
dancing_women👯‍♀️
sauna_person🧖
sauna_man🧖‍♂️
sauna_woman🧖‍♀️
climbing🧗
climbing_man🧗‍♂️
climbing_woman🧗‍♀️
person_fencing🤺
horse_racing🏇
skier⛷️
snowboarder🏂
golfing🏌️
golfing_man🏌️‍♂️
golfing_woman🏌️‍♀️
surfer🏄
surfing_man🏄‍♂️
surfing_woman🏄‍♀️
rowboat🚣
rowing_man🚣‍♂️
rowing_woman🚣‍♀️
swimmer🏊
swimming_man🏊‍♂️
swimming_woman🏊‍♀️
bouncing_ball_person⛹️
bouncing_ball_man⛹️‍♂️
bouncing_ball_woman⛹️‍♀️
weight_lifting🏋️
weight_lifting_man🏋️‍♂️
weight_lifting_woman🏋️‍♀️
bicyclist🚴
biking_man🚴‍♂️
biking_woman🚴‍♀️
mountain_bicyclist🚵
mountain_biking_man🚵‍♂️
mountain_biking_woman🚵‍♀️
cartwheeling🤸
man_cartwheeling🤸‍♂️
woman_cartwheeling🤸‍♀️
wrestling🤼
men_wrestling🤼‍♂️
women_wrestling🤼‍♀️
water_polo🤽
man_playing_water_polo🤽‍♂️
woman_playing_water_polo🤽‍♀️
handball_person🤾
man_playing_handball🤾‍♂️
woman_playing_handball🤾‍♀️
juggling_person🤹
man_juggling🤹‍♂️
woman_juggling🤹‍♀️
lotus_position🧘
lotus_position_man🧘‍♂️
lotus_position_woman🧘‍♀️
bath🛀
sleeping_bed🛌
people_holding_hands🧑‍🤝‍🧑
two_women_holding_hands👭
couple👫
two_men_holding_hands👬
couplekiss💏
couplekiss_man_woman👩‍❤️‍💋‍👨
couplekiss_man_man👨‍❤️‍💋‍👨
couplekiss_woman_woman👩‍❤️‍💋‍👩
couple_with_heart💑
couple_with_heart_woman_man👩‍❤️‍👨
couple_with_heart_man_man👨‍❤️‍👨
couple_with_heart_woman_woman👩‍❤️‍👩
family👪
family_man_woman_boy👨‍👩‍👦
family_man_woman_girl👨‍👩‍👧
family_man_woman_girl_boy👨‍👩‍👧‍👦
family_man_woman_boy_boy👨‍👩‍👦‍👦
family_man_woman_girl_girl👨‍👩‍👧‍👧
family_man_man_boy👨‍👨‍👦
family_man_man_girl👨‍👨‍👧
family_man_man_girl_boy👨‍👨‍👧‍👦
family_man_man_boy_boy👨‍👨‍👦‍👦
family_man_man_girl_girl👨‍👨‍👧‍👧
family_woman_woman_boy👩‍👩‍👦
family_woman_woman_girl👩‍👩‍👧
family_woman_woman_girl_boy👩‍👩‍👧‍👦
family_woman_woman_boy_boy👩‍👩‍👦‍👦
family_woman_woman_girl_girl👩‍👩‍👧‍👧
family_man_boy👨‍👦
family_man_boy_boy👨‍👦‍👦
family_man_girl👨‍👧
family_man_girl_boy👨‍👧‍👦
family_man_girl_girl👨‍👧‍👧
family_woman_boy👩‍👦
family_woman_boy_boy👩‍👦‍👦
family_woman_girl👩‍👧
family_woman_girl_boy👩‍👧‍👦
family_woman_girl_girl👩‍👧‍👧
speaking_head🗣️
bust_in_silhouette👤
busts_in_silhouette👥
people_hugging🫂
footprints👣
monkey_face🐵
monkey🐒
gorilla🦍
orangutan🦧
dog🐶
dog2🐕
guide_dog🦮
service_dog🐕‍🦺
poodle🐩
wolf🐺
fox_face🦊
raccoon🦝
cat🐱
cat2🐈
black_cat🐈‍⬛
lion🦁
tiger🐯
tiger2🐅
leopard🐆
horse🐴
racehorse🐎
unicorn🦄
zebra🦓
deer🦌
bison🦬
cow🐮
ox🐂
water_buffalo🐃
cow2🐄
pig🐷
pig2🐖
boar🐗
pig_nose🐽
ram🐏
sheep🐑
goat🐐
dromedary_camel🐪
camel🐫
llama🦙
giraffe🦒
elephant🐘
mammoth🦣
rhinoceros🦏
hippopotamus🦛
mouse🐭
mouse2🐁
rat🐀
hamster🐹
rabbit🐰
rabbit2🐇
chipmunk🐿️
beaver🦫
hedgehog🦔
bat🦇
bear🐻
polar_bear🐻‍❄️
koala🐨
panda_face🐼
sloth🦥
otter🦦
skunk🦨
kangaroo🦘
badger🦡
feet🐾
turkey🦃
chicken🐔
rooster🐓
hatching_chick🐣
baby_chick🐤
hatched_chick🐥
bird🐦
penguin🐧
dove🕊️
eagle🦅
duck🦆
swan🦢
owl🦉
dodo🦤
feather🪶
flamingo🦩
peacock🦚
parrot🦜
frog🐸
crocodile🐊
turtle🐢
lizard🦎
snake🐍
dragon_face🐲
dragon🐉
sauropod🦕
t-rex🦖
whale🐳
whale2🐋
dolphin🐬
seal🦭
fish🐟
tropical_fish🐠
blowfish🐡
shark🦈
octopus🐙
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
octopus🐙
shell🐚
snail🐌
butterfly🦋
bug🐛
ant🐜
bee🐝
beetle🪲
lady_beetle🐞
cricket🦗
cockroach🪳
spider🕷️
spider_web🕸️
scorpion🦂
mosquito🦟
fly🪰
worm🪱
microbe🦠
bouquet💐
cherry_blossom🌸
white_flower💮
rosette🏵️
rose🌹
wilted_flower🥀
hibiscus🌺
sunflower🌻
blossom🌼
tulip🌷
seedling🌱
potted_plant🪴
evergreen_tree🌲
deciduous_tree🌳
palm_tree🌴
cactus🌵
ear_of_rice🌾
herb🌿
shamrock☘️
four_leaf_clover🍀
maple_leaf🍁
fallen_leaf🍂
leaves🍃
grapes🍇
melon🍈
watermelon🍉
tangerine🍊
lemon🍋
banana🍌
pineapple🍍
mango🥭
apple🍎
green_apple🍏
pear🍐
peach🍑
cherries🍒
strawberry🍓
blueberries🫐
kiwi_fruit🥝
tomato🍅
olive🫒
coconut🥥
avocado🥑
eggplant🍆
potato🥔
carrot🥕
corn🌽
hot_pepper🌶️
bell_pepper🫑
cucumber🥒
leafy_green🥬
broccoli🥦
garlic🧄
onion🧅
mushroom🍄
peanuts🥜
chestnut🌰
bread🍞
croissant🥐
baguette_bread🥖
flatbread🫓
pretzel🥨
bagel🥯
pancakes🥞
waffle🧇
cheese🧀
meat_on_bone🍖
poultry_leg🍗
cut_of_meat🥩
bacon🥓
hamburger🍔
fries🍟
pizza🍕
hotdog🌭
sandwich🥪
taco🌮
burrito🌯
tamale🫔
stuffed_flatbread🥙
falafel🧆
egg🥚
fried_egg🍳
shallow_pan_of_food🥘
stew🍲
fondue🫕
bowl_with_spoon🥣
green_salad🥗
popcorn🍿
butter🧈
salt🧂
canned_food🥫
bento🍱
rice_cracker🍘
rice_ball🍙
rice🍚
curry🍛
ramen🍜
spaghetti🍝
sweet_potato🍠
oden🍢
sushi🍣
fried_shrimp🍤
fish_cake🍥
moon_cake🥮
dango🍡
dumpling🥟
fortune_cookie🥠
takeout_box🥡
crab🦀
lobster🦞
shrimp🦐
squid🦑
oyster🦪
icecream🍦
shaved_ice🍧
ice_cream🍨
doughnut🍩
cookie🍪
birthday🎂
cake🍰
cupcake🧁
pie🥧
chocolate_bar🍫
candy🍬
lollipop🍭
custard🍮
honey_pot🍯
baby_bottle🍼
milk_glass🥛
coffee
teapot🫖
tea🍵
sake🍶
champagne🍾
wine_glass🍷
cocktail🍸
tropical_drink🍹
beer🍺
beers🍻
clinking_glasses🥂
tumbler_glass🥃
cup_with_straw🥤
bubble_tea🧋
beverage_box🧃
mate🧉
ice_cube🧊
chopsticks🥢
plate_with_cutlery🍽️
fork_and_knife🍴
spoon🥄
hocho🔪
amphora🏺
earth_africa🌍
earth_americas🌎
earth_asia🌏
globe_with_meridians🌐
world_map🗺️
japan🗾
compass🧭
mountain_snow🏔️
mountain⛰️
volcano🌋
mount_fuji🗻
camping🏕️
beach_umbrella🏖️
desert🏜️
desert_island🏝️
national_park🏞️
stadium🏟️
classical_building🏛️
building_construction🏗️
bricks🧱
rock🪨
wood🪵
hut🛖
houses🏘️
derelict_house🏚️
house🏠
house_with_garden🏡
office🏢
post_office🏣
european_post_office🏤
hospital🏥
bank🏦
hotel🏨
love_hotel🏩
convenience_store🏪
school🏫
department_store🏬
factory🏭
japanese_castle🏯
european_castle🏰
wedding💒
tokyo_tower🗼
statue_of_liberty🗽
church
mosque🕌
hindu_temple🛕
synagogue🕍
shinto_shrine⛩️
kaaba🕋
fountain
tent
foggy🌁
night_with_stars🌃
cityscape🏙️
sunrise_over_mountains🌄
sunrise🌅
city_sunset🌆
city_sunrise🌇
bridge_at_night🌉
hotsprings♨️
carousel_horse🎠
ferris_wheel🎡
roller_coaster🎢
barber💈
circus_tent🎪
steam_locomotive🚂
railway_car🚃
bullettrain_side🚄
bullettrain_front🚅
train2🚆
metro🚇
light_rail🚈
station🚉
tram🚊
monorail🚝
mountain_railway🚞
train🚋
bus🚌
oncoming_bus🚍
trolleybus🚎
minibus🚐
ambulance🚑
fire_engine🚒
police_car🚓
oncoming_police_car🚔
taxi🚕
oncoming_taxi🚖
car🚗
oncoming_automobile🚘
blue_car🚙
pickup_truck🛻
truck🚚
articulated_lorry🚛
tractor🚜
racing_car🏎️
motorcycle🏍️
motor_scooter🛵
manual_wheelchair🦽
motorized_wheelchair🦼
auto_rickshaw🛺
bike🚲
kick_scooter🛴
skateboard🛹
roller_skate🛼
busstop🚏
motorway🛣️
railway_track🛤️
oil_drum🛢️
fuelpump
rotating_light🚨
traffic_light🚥
vertical_traffic_light🚦
stop_sign🛑
construction🚧
anchor
boat
canoe🛶
speedboat🚤
passenger_ship🛳️
ferry⛴️
motor_boat🛥️
ship🚢
airplane✈️
small_airplane🛩️
flight_departure🛫
flight_arrival🛬
parachute🪂
seat💺
helicopter🚁
suspension_railway🚟
mountain_cableway🚠
aerial_tramway🚡
artificial_satellite🛰️
rocket🚀
flying_saucer🛸
bellhop_bell🛎️
luggage🧳
hourglass
hourglass_flowing_sand
watch
alarm_clock
stopwatch⏱️
timer_clock⏲️
mantelpiece_clock🕰️
clock12🕛
clock1230🕧
clock1🕐
clock130🕜
clock2🕑
clock230🕝
clock3🕒
clock330🕞
clock4🕓
clock430🕟
clock5🕔
clock530🕠
clock6🕕
clock630🕡
clock7🕖
clock730🕢
clock8🕗
clock830🕣
clock9🕘
clock930🕤
clock10🕙
clock1030🕥
clock11🕚
clock1130🕦
new_moon🌑
waxing_crescent_moon🌒
first_quarter_moon🌓
moon🌔
full_moon🌕
waning_gibbous_moon🌖
last_quarter_moon🌗
waning_crescent_moon🌘
crescent_moon🌙
new_moon_with_face🌚
first_quarter_moon_with_face🌛
last_quarter_moon_with_face🌜
thermometer🌡️
sunny☀️
full_moon_with_face🌝
sun_with_face🌞
ringed_planet🪐
star
star2🌟
stars🌠
milky_way🌌
cloud☁️
partly_sunny
cloud_with_lightning_and_rain⛈️
sun_behind_small_cloud🌤️
sun_behind_large_cloud🌥️
sun_behind_rain_cloud🌦️
cloud_with_rain🌧️
cloud_with_snow🌨️
cloud_with_lightning🌩️
tornado🌪️
fog🌫️
wind_face🌬️
cyclone🌀
rainbow🌈
closed_umbrella🌂
open_umbrella☂️
umbrella
parasol_on_ground⛱️
zap
snowflake❄️
snowman_with_snow☃️
snowman
comet☄️
fire🔥
droplet💧
ocean🌊
jack_o_lantern🎃
christmas_tree🎄
fireworks🎆
sparkler🎇
firecracker🧨
sparkles
balloon🎈
tada🎉
confetti_ball🎊
tanabata_tree🎋
bamboo🎍
dolls🎎
flags🎏
wind_chime🎐
rice_scene🎑
red_envelope🧧
ribbon🎀
gift🎁
reminder_ribbon🎗️
tickets🎟️
ticket🎫
medal_military🎖️
trophy🏆
medal_sports🏅
1st_place_medal🥇
2nd_place_medal🥈
3rd_place_medal🥉
soccer
baseball
softball🥎
basketball🏀
volleyball🏐
football🏈
rugby_football🏉
tennis🎾
flying_disc🥏
bowling🎳
cricket_game🏏
field_hockey🏑
ice_hockey🏒
lacrosse🥍
ping_pong🏓
badminton🏸
boxing_glove🥊
martial_arts_uniform🥋
goal_net🥅
golf
ice_skate⛸️
fishing_pole_and_fish🎣
diving_mask🤿
running_shirt_with_sash🎽
ski🎿
sled🛷
curling_stone🥌
dart🎯
yo_yo🪀
kite🪁
8ball🎱
crystal_ball🔮
magic_wand🪄
nazar_amulet🧿
video_game🎮
joystick🕹️
slot_machine🎰
game_die🎲
jigsaw🧩
teddy_bear🧸
pinata🪅
nesting_dolls🪆
spades♠️
hearts♥️
diamonds♦️
clubs♣️
chess_pawn♟️
black_joker🃏
mahjong🀄
flower_playing_cards🎴
performing_arts🎭
framed_picture🖼️
art🎨
thread🧵
sewing_needle🪡
yarn🧶
knot🪢
eyeglasses👓
dark_sunglasses🕶️
goggles🥽
lab_coat🥼
safety_vest🦺
necktie👔
shirt👕
jeans👖
scarf🧣
gloves🧤
coat🧥
socks🧦
dress👗
kimono👘
sari🥻
one_piece_swimsuit🩱
swim_brief🩲
shorts🩳
bikini👙
womans_clothes👚
purse👛
handbag👜
pouch👝
shopping🛍️
school_satchel🎒
thong_sandal🩴
mans_shoe👞
athletic_shoe👟
hiking_boot🥾
flat_shoe🥿
high_heel👠
sandal👡
ballet_shoes🩰
boot👢
crown👑
womans_hat👒
tophat🎩
mortar_board🎓
billed_cap🧢
military_helmet🪖
rescue_worker_helmet⛑️
prayer_beads📿
lipstick💄
ring💍
gem💎
mute🔇
speaker🔈
sound🔉
loud_sound🔊
loudspeaker📢
mega📣
postal_horn📯
bell🔔
no_bell🔕
musical_score🎼
musical_note🎵
notes🎶
studio_microphone🎙️
level_slider🎚️
control_knobs🎛️
microphone🎤
headphones🎧
radio📻
saxophone🎷
accordion🪗
guitar🎸
musical_keyboard🎹
trumpet🎺
violin🎻
banjo🪕
drum🥁
long_drum🪘
iphone📱
calling📲
phone☎️
telephone_receiver📞
pager📟
fax📠
battery🔋
electric_plug🔌
computer💻
desktop_computer🖥️
printer🖨️
keyboard⌨️
computer_mouse🖱️
trackball🖲️
minidisc💽
floppy_disk💾
cd💿
dvd📀
abacus🧮
movie_camera🎥
film_strip🎞️
film_projector📽️
clapper🎬
tv📺
camera📷
camera_flash📸
video_camera📹
vhs📼
mag🔍
mag_right🔎
candle🕯️
bulb💡
flashlight🔦
izakaya_lantern🏮
diya_lamp🪔
notebook_with_decorative_cover📔
closed_book📕
book📖
green_book📗
blue_book📘
orange_book📙
books📚
notebook📓
ledger📒
page_with_curl📃
scroll📜
page_facing_up📄
newspaper📰
newspaper_roll🗞️
bookmark_tabs📑
bookmark🔖
label🏷️
moneybag💰
coin🪙
yen💴
dollar💵
euro💶
pound💷
money_with_wings💸
credit_card💳
receipt🧾
chart💹
envelope✉️
email📧
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
email📧
incoming_envelope📨
envelope_with_arrow📩
outbox_tray📤
inbox_tray📥
package📦
mailbox📫
mailbox_closed📪
mailbox_with_mail📬
mailbox_with_no_mail📭
postbox📮
ballot_box🗳️
pencil2✏️
black_nib✒️
fountain_pen🖋️
pen🖊️
paintbrush🖌️
crayon🖍️
memo📝
briefcase💼
file_folder📁
open_file_folder📂
card_index_dividers🗂️
date📅
calendar📆
spiral_notepad🗒️
spiral_calendar🗓️
card_index📇
chart_with_upwards_trend📈
chart_with_downwards_trend📉
bar_chart📊
clipboard📋
pushpin📌
round_pushpin📍
paperclip📎
paperclips🖇️
straight_ruler📏
triangular_ruler📐
scissors✂️
card_file_box🗃️
file_cabinet🗄️
wastebasket🗑️
lock🔒
unlock🔓
lock_with_ink_pen🔏
closed_lock_with_key🔐
key🔑
old_key🗝️
hammer🔨
axe🪓
pick⛏️
hammer_and_pick⚒️
hammer_and_wrench🛠️
dagger🗡️
crossed_swords⚔️
gun🔫
boomerang🪃
bow_and_arrow🏹
shield🛡️
carpentry_saw🪚
wrench🔧
screwdriver🪛
nut_and_bolt🔩
gear⚙️
clamp🗜️
balance_scale⚖️
probing_cane🦯
link🔗
chains⛓️
hook🪝
toolbox🧰
magnet🧲
ladder🪜
alembic⚗️
test_tube🧪
petri_dish🧫
dna🧬
microscope🔬
telescope🔭
satellite📡
syringe💉
drop_of_blood🩸
pill💊
adhesive_bandage🩹
stethoscope🩺
door🚪
elevator🛗
mirror🪞
window🪟
bed🛏️
couch_and_lamp🛋️
chair🪑
toilet🚽
plunger🪠
shower🚿
bathtub🛁
mouse_trap🪤
razor🪒
lotion_bottle🧴
safety_pin🧷
broom🧹
basket🧺
roll_of_paper🧻
bucket🪣
soap🧼
toothbrush🪥
sponge🧽
fire_extinguisher🧯
shopping_cart🛒
smoking🚬
coffin⚰️
headstone🪦
funeral_urn⚱️
moyai🗿
placard🪧
atm🏧
put_litter_in_its_place🚮
potable_water🚰
wheelchair
mens🚹
womens🚺
restroom🚻
baby_symbol🚼
wc🚾
passport_control🛂
customs🛃
baggage_claim🛄
left_luggage🛅
warning⚠️
children_crossing🚸
no_entry
no_entry_sign🚫
no_bicycles🚳
no_smoking🚭
do_not_litter🚯
non-potable_water🚱
no_pedestrians🚷
no_mobile_phones📵
underage🔞
radioactive☢️
biohazard☣️
arrow_up⬆️
arrow_upper_right↗️
arrow_right➡️
arrow_lower_right↘️
arrow_down⬇️
arrow_lower_left↙️
arrow_left⬅️
arrow_upper_left↖️
arrow_up_down↕️
left_right_arrow↔️
leftwards_arrow_with_hook↩️
arrow_right_hook↪️
arrow_heading_up⤴️
arrow_heading_down⤵️
arrows_clockwise🔃
arrows_counterclockwise🔄
back🔙
end🔚
on🔛
soon🔜
top🔝
place_of_worship🛐
atom_symbol⚛️
om🕉️
star_of_david✡️
wheel_of_dharma☸️
yin_yang☯️
latin_cross✝️
orthodox_cross☦️
star_and_crescent☪️
peace_symbol☮️
menorah🕎
six_pointed_star🔯
aries
taurus
gemini
cancer
leo
virgo
libra
scorpius
sagittarius
capricorn
aquarius
pisces
ophiuchus
twisted_rightwards_arrows🔀
repeat🔁
repeat_one🔂
arrow_forward▶️
fast_forward
next_track_button⏭️
play_or_pause_button⏯️
arrow_backward◀️
rewind
previous_track_button⏮️
arrow_up_small🔼
arrow_double_up
arrow_down_small🔽
arrow_double_down
pause_button⏸️
stop_button⏹️
record_button⏺️
eject_button⏏️
cinema🎦
low_brightness🔅
high_brightness🔆
signal_strength📶
vibration_mode📳
mobile_phone_off📴
female_sign♀️
male_sign♂️
transgender_symbol⚧️
heavy_multiplication_x✖️
heavy_plus_sign
heavy_minus_sign
heavy_division_sign
infinity♾️
bangbang‼️
interrobang⁉️
question
grey_question
grey_exclamation
exclamation
wavy_dash〰️
currency_exchange💱
heavy_dollar_sign💲
medical_symbol⚕️
recycle♻️
fleur_de_lis⚜️
trident🔱
name_badge📛
beginner🔰
o
white_check_mark
ballot_box_with_check☑️
heavy_check_mark✔️
x
negative_squared_cross_mark
curly_loop
loop
part_alternation_mark〽️
eight_spoked_asterisk✳️
eight_pointed_black_star✴️
sparkle❇️
copyright©️
registered®️
tm™️
hash#️⃣
asterisk*️⃣
zero0️⃣
one1️⃣
two2️⃣
three3️⃣
four4️⃣
five5️⃣
six6️⃣
seven7️⃣
eight8️⃣
nine9️⃣
keycap_ten🔟
capital_abcd🔠
abcd🔡
1234🔢
symbols🔣
abc🔤
a🅰️
ab🆎
b🅱️
cl🆑
cool🆒
free🆓
information_sourceℹ️
id🆔
mⓂ️
new🆕
ng🆖
o2🅾️
ok🆗
parking🅿️
sos🆘
up🆙
vs🆚
koko🈁
sa🈂️
u6708🈷️
u6709🈶
u6307🈯
ideograph_advantage🉐
u5272🈹
u7121🈚
u7981🈲
accept🉑
u7533🈸
u5408🈴
u7a7a🈳
congratulations㊗️
secret㊙️
u55b6🈺
u6e80🈵
red_circle🔴
orange_circle🟠
yellow_circle🟡
green_circle🟢
large_blue_circle🔵
purple_circle🟣
brown_circle🟤
black_circle
white_circle
red_square🟥
orange_square🟧
yellow_square🟨
green_square🟩
blue_square🟦
purple_square🟪
brown_square🟫
black_large_square
white_large_square
black_medium_square◼️
white_medium_square◻️
black_medium_small_square
white_medium_small_square
black_small_square▪️
white_small_square▫️
large_orange_diamond🔶
large_blue_diamond🔷
small_orange_diamond🔸
small_blue_diamond🔹
small_red_triangle🔺
small_red_triangle_down🔻
diamond_shape_with_a_dot_inside💠
radio_button🔘
white_square_button🔳
black_square_button🔲
checkered_flag🏁
triangular_flag_on_post🚩
crossed_flags🎌
black_flag🏴
white_flag🏳️
rainbow_flag🏳️‍🌈
transgender_flag🏳️‍⚧️
pirate_flag🏴‍☠️
ascension_island🇦🇨
andorra🇦🇩
united_arab_emirates🇦🇪
afghanistan🇦🇫
antigua_barbuda🇦🇬
anguilla🇦🇮
albania🇦🇱
armenia🇦🇲
angola🇦🇴
antarctica🇦🇶
argentina🇦🇷
american_samoa🇦🇸
austria🇦🇹
australia🇦🇺
aruba🇦🇼
aland_islands🇦🇽
azerbaijan🇦🇿
bosnia_herzegovina🇧🇦
barbados🇧🇧
bangladesh🇧🇩
belgium🇧🇪
burkina_faso🇧🇫
bulgaria🇧🇬
bahrain🇧🇭
burundi🇧🇮
benin🇧🇯
st_barthelemy🇧🇱
bermuda🇧🇲
brunei🇧🇳
bolivia🇧🇴
caribbean_netherlands🇧🇶
brazil🇧🇷
bahamas🇧🇸
bhutan🇧🇹
bouvet_island🇧🇻
botswana🇧🇼
belarus🇧🇾
belize🇧🇿
canada🇨🇦
cocos_islands🇨🇨
congo_kinshasa🇨🇩
central_african_republic🇨🇫
congo_brazzaville🇨🇬
switzerland🇨🇭
cote_divoire🇨🇮
cook_islands🇨🇰
chile🇨🇱
cameroon🇨🇲
cn🇨🇳
colombia🇨🇴
clipperton_island🇨🇵
costa_rica🇨🇷
cuba🇨🇺
cape_verde🇨🇻
curacao🇨🇼
christmas_island🇨🇽
cyprus🇨🇾
czech_republic🇨🇿
de🇩🇪
diego_garcia🇩🇬
djibouti🇩🇯
denmark🇩🇰
dominica🇩🇲
dominican_republic🇩🇴
algeria🇩🇿
ceuta_melilla🇪🇦
ecuador🇪🇨
estonia🇪🇪
egypt🇪🇬
western_sahara🇪🇭
eritrea🇪🇷
es🇪🇸
ethiopia🇪🇹
eu🇪🇺
finland🇫🇮
fiji🇫🇯
falkland_islands🇫🇰
micronesia🇫🇲
faroe_islands🇫🇴
fr🇫🇷
gabon🇬🇦
gb🇬🇧
grenada🇬🇩
georgia🇬🇪
french_guiana🇬🇫
guernsey🇬🇬
ghana🇬🇭
gibraltar🇬🇮
greenland🇬🇱
gambia🇬🇲
guinea🇬🇳
guadeloupe🇬🇵
equatorial_guinea🇬🇶
greece🇬🇷
south_georgia_south_sandwich_islands🇬🇸
guatemala🇬🇹
guam🇬🇺
guinea_bissau🇬🇼
guyana🇬🇾
hong_kong🇭🇰
heard_mcdonald_islands🇭🇲
honduras🇭🇳
croatia🇭🇷
haiti🇭🇹
hungary🇭🇺
canary_islands🇮🇨
indonesia🇮🇩
ireland🇮🇪
israel🇮🇱
isle_of_man🇮🇲
india🇮🇳
british_indian_ocean_territory🇮🇴
iraq🇮🇶
iran🇮🇷
iceland🇮🇸
it🇮🇹
jersey🇯🇪
jamaica🇯🇲
jordan🇯🇴
jp🇯🇵
kenya🇰🇪
kyrgyzstan🇰🇬
cambodia🇰🇭
kiribati🇰🇮
comoros🇰🇲
st_kitts_nevis🇰🇳
north_korea🇰🇵
kr🇰🇷
kuwait🇰🇼
cayman_islands🇰🇾
kazakhstan🇰🇿
laos🇱🇦
lebanon🇱🇧
st_lucia🇱🇨
liechtenstein🇱🇮
sri_lanka🇱🇰
liberia🇱🇷
lesotho🇱🇸
lithuania🇱🇹
luxembourg🇱🇺
latvia🇱🇻
libya🇱🇾
morocco🇲🇦
monaco🇲🇨
moldova🇲🇩
montenegro🇲🇪
st_martin🇲🇫
madagascar🇲🇬
marshall_islands🇲🇭
macedonia🇲🇰
mali🇲🇱
myanmar🇲🇲
mongolia🇲🇳
macau🇲🇴
northern_mariana_islands🇲🇵
martinique🇲🇶
mauritania🇲🇷
montserrat🇲🇸
malta🇲🇹
mauritius🇲🇺
maldives🇲🇻
malawi🇲🇼
mexico🇲🇽
malaysia🇲🇾
mozambique🇲🇿
namibia🇳🇦
new_caledonia🇳🇨
niger🇳🇪
norfolk_island🇳🇫
nigeria🇳🇬
nicaragua🇳🇮
netherlands🇳🇱
norway🇳🇴
nepal🇳🇵
nauru🇳🇷
niue🇳🇺
new_zealand🇳🇿
oman🇴🇲
panama🇵🇦
peru🇵🇪
french_polynesia🇵🇫
papua_new_guinea🇵🇬
philippines🇵🇭
pakistan🇵🇰
poland🇵🇱
st_pierre_miquelon🇵🇲
pitcairn_islands🇵🇳
puerto_rico🇵🇷
palestinian_territories🇵🇸
portugal🇵🇹
palau🇵🇼
paraguay🇵🇾
qatar🇶🇦
reunion🇷🇪
romania🇷🇴
serbia🇷🇸
ru🇷🇺
rwanda🇷🇼
saudi_arabia🇸🇦
solomon_islands🇸🇧
seychelles🇸🇨
sudan🇸🇩
sweden🇸🇪
singapore🇸🇬
st_helena🇸🇭
slovenia🇸🇮
svalbard_jan_mayen🇸🇯
slovakia🇸🇰
sierra_leone🇸🇱
san_marino🇸🇲
senegal🇸🇳
somalia🇸🇴
suriname🇸🇷
south_sudan🇸🇸
sao_tome_principe🇸🇹
el_salvador🇸🇻
sint_maarten🇸🇽
syria🇸🇾
swaziland🇸🇿
tristan_da_cunha🇹🇦
turks_caicos_islands🇹🇨
chad🇹🇩
french_southern_territories🇹🇫
togo🇹🇬
thailand🇹🇭
tajikistan🇹🇯
tokelau🇹🇰
timor_leste🇹🇱
turkmenistan🇹🇲
tunisia🇹🇳
tonga🇹🇴
tr🇹🇷
trinidad_tobago🇹🇹
tuvalu🇹🇻
taiwan🇹🇼
tanzania🇹🇿
ukraine🇺🇦
uganda🇺🇬
us_outlying_islands🇺🇲
united_nations🇺🇳
us🇺🇸
uruguay🇺🇾
uzbekistan🇺🇿
vatican_city🇻🇦
st_vincent_grenadines🇻🇨
venezuela🇻🇪
british_virgin_islands🇻🇬
us_virgin_islands🇻🇮
vietnam🇻🇳
vanuatu🇻🇺
wallis_futuna🇼🇫
samoa🇼🇸
kosovo🇽🇰
yemen🇾🇪
mayotte🇾🇹
south_africa🇿🇦
zambia🇿🇲
zimbabwe🇿🇼
england🏴󠁧󠁢󠁥󠁮󠁧󠁿
scotland🏴󠁧󠁢󠁳󠁣󠁴󠁿
wales🏴󠁧󠁢󠁷󠁬󠁳󠁿
diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 00000000..4e936d91 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,619 @@ +# Examples + +There are a million ways to use ntfy, but here are some inspirations. I try to collect +examples on GitHub, so be sure to check +those out, too. + +!!! info + Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please + [create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that + I cannot guarantee that all of these examples are functional. Many of them I have not tried myself. + +## Cronjobs +ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...). + +I started adding notifications pretty much all of my scripts. Typically, I just chain the curl call +directly to the command I'm running. The following example will either send Laptop backup succeeded +or ⚠️ Laptop backup failed directly to my phone: + +``` bash +rsync -a root@laptop /backups/laptop \ + && zfs snapshot ... \ + && curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \ + || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups +``` + +Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with +GitHub have been hopeless. In case it ever becomes available, I want to know immediately. + +``` +# Check github/ntfy user +*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi +``` + + +## Low disk space alerts +Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but +effective. + +``` bash +#!/bin/bash + +mingigs=10 +avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }') +topicurl=https://ntfy.sh/mytopic + +if [ -n "$avail" ]; then + curl \ + -d "Only $avail GB available on the root disk. Better clean that up." \ + -H "Title: Low disk space alert on $(hostname)" \ + -H "Priority: high" \ + -H "Tags: warning,cd" \ + $topicurl +fi +``` + +## SSH login alerts +Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I +own, I now message myself. Here's an example of how to use PAM +to notify yourself on SSH login. + +=== "/etc/pam.d/sshd" + ``` + # at the end of the file + session optional pam_exec.so /usr/bin/ntfy-ssh-login.sh + ``` + +=== "/usr/bin/ntfy-ssh-login.sh" + ```bash + #!/bin/bash + if [ "${PAM_TYPE}" = "open_session" ]; then + curl \ + -H prio:high \ + -H tags:warning \ + -d "SSH login: ${PAM_USER} from ${PAM_RHOST}" \ + ntfy.sh/alerts + fi + ``` + +## Collect data from multiple machines +The other day I was running tasks on 20 servers, and I wanted to collect the interim results +as a CSV in one place. Each of the servers was publishing to a topic as the results completed (`publish-result.sh`), +and I had one central collector to grab the results as they came in (`collect-results.sh`). + +It looked something like this: + +=== "collect-results.sh" + ```bash + while read result; do + [ -n "$result" ] && echo "$result" >> results.csv + done < <(stdbuf -i0 -o0 curl -s ntfy.sh/results/raw) + ``` +=== "publish-result.sh" + ```bash + // This script was run on each of the 20 servers. It was doing heavy processing ... + + // Publish script results + curl -d "$(hostname),$count,$time" ntfy.sh/results + ``` + +## Ansible, Salt and Puppet +You can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated. +One of my co-workers uses the following Ansible task to let him know when things are done: + +``` yaml +- name: Send ntfy.sh update + uri: + url: "https://ntfy.sh/{{ ntfy_channel }}" + method: POST + body: "{{ inventory_hostname }} reseeding complete" +``` + +There's also a dedicated Ansible action plugin (one which runs on the Ansible controller) called +[ansible-ntfy](https://github.com/jpmens/ansible-ntfy). The following task posts a message +to ntfy at its default URL (`attrs` and other attributes are optional): + +``` yaml +- name: "Notify ntfy that we're done" + ntfy: + msg: "deployment on {{ inventory_hostname }} is complete. 🐄" + attrs: + tags: [ heavy_check_mark ] + priority: 1 +``` + +## GitHub Actions +You can send a message during a workflow run with curl. Here is an example sending info about the repo, commit and job status. +``` yaml +- name: Actions Ntfy + run: | + curl \ + -u ${{ secrets.NTFY_CRED }} \ + -H "Title: Title here" \ + -H "Content-Type: text/plain" \ + -d $'Repo: ${{ github.repository }}\nCommit: ${{ github.sha }}\nRef: ${{ github.ref }}\nStatus: ${{ job.status}}' \ + ${{ secrets.NTFY_URL }} +``` + +## Changedetection.io +ntfy is an excellent choice for getting notifications when a website has a change sent to your mobile (or desktop), +[changedetection.io](https://changedetection.io) or on GitHub ([dgtlmoon/changedetection.io](https://github.com/dgtlmoon/changedetection.io)) +uses [apprise](https://github.com/caronc/apprise) library for notification integrations. + +To add any ntfy(s) notification to a website change simply add the [ntfy style URL](https://github.com/caronc/apprise/wiki/Notify_ntfy) +to the notification list. + +For example `ntfy://{topic}` or `ntfy://{user}:{password}@{host}:{port}/{topics}` + +In your changedetection.io installation, click `Edit` > `Notifications` on a single website watch (or group) then add +the special ntfy Apprise Notification URL to the Notification List. + +![ntfy alerts on website change](static/img/cdio-setup.jpg) + +## Watchtower (shoutrrr) +You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send +[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. + +Example docker-compose.yml: + +``` yaml +services: + watchtower: + image: containrrr/watchtower + environment: + - WATCHTOWER_NOTIFICATIONS=shoutrrr + - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates +``` + +Or, if you only want to send notifications using shoutrrr: +``` +shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" +``` + +## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd + + + +Radarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect. + +Sonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc. +Some simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts). + +## Node-RED +You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples: + +
+Example: Send a message (click to expand) + +``` json +[ + { + "id": "c956e688cc74ad8e", + "type": "http request", + "z": "fabdd7a3.4045a", + "name": "ntfy.sh", + "method": "POST", + "ret": "txt", + "paytoqs": "ignore", + "url": "https://ntfy.sh/mytopic", + "tls": "", + "persist": false, + "proxy": "", + "authType": "", + "senderr": false, + "credentials": + { + "user": "", + "password": "" + }, + "x": 590, + "y": 3160, + "wires": + [ + [] + ] + }, + { + "id": "32ee1eade51fae50", + "type": "function", + "z": "fabdd7a3.4045a", + "name": "data", + "func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 470, + "y": 3160, + "wires": + [ + [ + "c956e688cc74ad8e" + ] + ] + }, + { + "id": "b287e59cd2311815", + "type": "inject", + "z": "fabdd7a3.4045a", + "name": "Manual start", + "props": + [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "20", + "topic": "", + "payload": "", + "payloadType": "date", + "x": 330, + "y": 3160, + "wires": + [ + [ + "32ee1eade51fae50" + ] + ] + } +] +``` + +
+ +![Node red message flow](static/img/nodered-message.png) + +
+Example: Send a picture (click to expand) + +``` json +[ + { + "id": "d135a13eadeb9d6d", + "type": "http request", + "z": "fabdd7a3.4045a", + "name": "Download image", + "method": "GET", + "ret": "bin", + "paytoqs": "ignore", + "url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png", + "tls": "", + "persist": false, + "proxy": "", + "authType": "", + "senderr": false, + "credentials": + { + "user": "", + "password": "" + }, + "x": 490, + "y": 3320, + "wires": + [ + [ + "6e75bc41d2ec4a03" + ] + ] + }, + { + "id": "6e75bc41d2ec4a03", + "type": "function", + "z": "fabdd7a3.4045a", + "name": "data", + "func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 650, + "y": 3320, + "wires": + [ + [ + "eb160615b6ceda98" + ] + ] + }, + { + "id": "eb160615b6ceda98", + "type": "http request", + "z": "fabdd7a3.4045a", + "name": "ntfy.sh", + "method": "PUT", + "ret": "bin", + "paytoqs": "ignore", + "url": "https://ntfy.sh/mytopic", + "tls": "", + "persist": false, + "proxy": "", + "authType": "", + "senderr": false, + "credentials": + { + "user": "", + "password": "" + }, + "x": 770, + "y": 3320, + "wires": + [ + [] + ] + }, + { + "id": "5b8dbf15c8a7a3a5", + "type": "inject", + "z": "fabdd7a3.4045a", + "name": "Manual start", + "props": + [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": "20", + "topic": "", + "payload": "", + "payloadType": "date", + "x": 310, + "y": 3320, + "wires": + [ + [ + "d135a13eadeb9d6d" + ] + ] + } +] +``` + +
+ +![Node red picture flow](static/img/nodered-picture.png) + +## Gatus +To use ntfy with [Gatus](https://github.com/TwiN/gatus), you can use the `ntfy` alerting provider like so: + +```yaml +alerting: + ntfy: + url: "https://ntfy.sh" + topic: "YOUR_NTFY_TOPIC" + priority: 3 +``` + +For more information on using ntfy with Gatus, refer to [Configuring ntfy alerts](https://github.com/TwiN/gatus#configuring-ntfy-alerts). + +
+ Alternative: Using the custom alerting provider + +```yaml +alerting: + custom: + url: "https://ntfy.sh" + method: "POST" + body: | + { + "topic": "mytopic", + "message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]", + "title": "Gatus", + "tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"], + "priority": 3 + } + default-alert: + enabled: true + description: "health check failed" + send-on-resolved: true + failure-threshold: 3 + success-threshold: 3 + placeholders: + ALERT_TRIGGERED_OR_RESOLVED: + TRIGGERED: "warning" + RESOLVED: "white_check_mark" +``` + +
+ + +## Jellyseerr/Overseerr webhook +Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook +JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click. +And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic. + +``` json +{ + "topic": "requests", + "title": "{{event}}", + "message": "{{subject}}\n{{message}}\n\nRequested by: {{requestedBy_username}}\n\nStatus: {{media_status}}\nRequest Id: {{request_id}}", + "priority": 4, + "attach": "{{image}}", + "click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}" +} +``` + +## Home Assistant +Here is an example for the configuration.yml file to setup a REST notify component. +Since Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource. + +```yaml +notify: + - name: ntfy + platform: rest + method: POST_JSON + data: + topic: YOUR_NTFY_TOPIC + title_param_name: title + message_param_name: message + resource: https://ntfy.sh +``` + +If you need to authenticate to your ntfy resource, define the authentication, username and password as below: + +```yaml +notify: + - name: ntfy + platform: rest + method: POST_JSON + authentication: basic + username: YOUR_USERNAME + password: YOUR_PASSWORD + data: + topic: YOUR_NTFY_TOPIC + title_param_name: title + message_param_name: message + resource: https://ntfy.sh +``` + +If you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example: + +```yaml +notify: + - name: ntfy + platform: rest + method: POST_JSON + data: + topic: YOUR_NTFY_TOPIC + priority: 4 + title_param_name: title + message_param_name: message + resource: https://ntfy.sh +``` + +## Uptime Kuma +Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**. +Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**: + +
+ + +
+ +You can now test the notifications and apply them to monitors: + +
+ + + +
+ +## UptimeRobot +Go to your [UptimeRobot](https://github.com/uptimerobot) My Settings > Alert Contacts > Add Alert Contact +Select **Alert Contact Type** = Webhook. Then set your desired **Friendly Name** (e.g. "ntfy-sh-UP"), **URL to Notify**, **POST value** and select checkbox **Send as JSON (application/json)**. Make sure to send the JSON POST request to ntfy.domain.com without the topic name in the url and include the "topic" name in the JSON body. + +
+ +
+ +``` json +{ + "topic":"myTopic", + "title": "*monitorFriendlyName* *alertTypeFriendlyName*", + "message": "*alertDetails*", + "tags": ["green_circle"], + "priority": 3, + "click": https://uptimerobot.com/dashboard#*monitorID* +} +``` +You can create two Alert Contacts each with a different icon and priority, for example: + +``` json +{ + "topic":"myTopic", + "title": "*monitorFriendlyName* *alertTypeFriendlyName*", + "message": "*alertDetails*", + "tags": ["red_circle"], + "priority": 3, + "click": https://uptimerobot.com/dashboard#*monitorID* +} +``` +You can now add the created Alerts Contact(s) to the monitor(s) and test the notifications: + +
+ +
+ + +## Apprise +ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the +[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)). + +You can use it like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://mytopic +``` + +Or with your own server like this: + +``` +apprise -vv -t "Test Message Title" -b "Test Message Body" \ + ntfy://ntfy.example.com/mytopic +``` + + +## Rundeck +Rundeck by default sends only HTML email which is not processed by ntfy SMTP server. Append following configurations to +[rundeck-config.properties](https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html) : + +``` +# Template +rundeck.mail.template.file=/path/to/template.html +rundeck.mail.template.log.formatted=false +``` + +Example `template.html`: +```html +
Execution ${execution.id} was ${execution.status}
+ +``` + +Add notification on Rundeck (attachment type must be: `Attached as file to email`): +![Rundeck](static/img/rundeck.png) + +## Traccar +This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes + +The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails. + +**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json)) +```xml + https://ntfy.sh + + { + "topic": "{phone}", + "message": "{message}" + } + +``` +If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token +```xml + Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ +``` +or by simply providing traccar with a valid username/password combination. +```xml + phil + mypass +``` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000..6ff97cfe --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,104 @@ +# Frequently asked questions (FAQ) + +## Isn't this like ...? +Who knows. I didn't do a lot of research before making this. It was fun making it. + +## Can I use this in my app? Will it stay free? +Yes. As long as you don't abuse it, it'll be available and free of charge. While I will always allow usage of the ntfy.sh +server without signup and free of charge, I may also offer paid plans in the future. + +## What are the uptime guarantees? +Best effort. + +ntfy currently runs on a single DigitalOcean droplet, without any scale out strategy or redundancies. When the time comes, +I'll add scale out features, but for now it is what it is. + +In the first year of its life, and to this day (Dec'22), ntfy had **no outages** that I can remember. Other than short +blips and some HTTP 500 spikes, it has been rock solid. + +There is a [status page](https://ntfy.statuspage.io/) which is updated based on some automated checks via the amazingly +awesome [healthchecks.io](https://healthchecks.io/) (_no affiliation, just a fan_). + +## What happens if there are multiple subscribers to the same topic? +As per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic. + +## Will you know what topics exist, can you spy on me? +If you don't trust me or your messages are sensitive, run your own server. It's open source. +That said, the logs do contain topic names and IP addresses, but I don't use them for anything other than +troubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default) +to facilitate service restarts, message polling and to overcome client network disruptions. + +## Can I self-host it? +Yes. The server (including this Web UI) can be self-hosted, and the Android/iOS app supports adding topics from +your own server as well. Check out the [install instructions](install.md). + +## Is Firebase used? +In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also +published to Firebase Cloud Messaging (FCM) (if `FirebaseKeyFile` is set, which it is on ntfy.sh). This +is to facilitate notifications on Android. + +If you do not care for Firebase, I suggest you install the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) +of the app and [self-host your own ntfy server](install.md). + +## How much battery does the Android app use? +If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature, +the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server, +or you use *instant delivery* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)), +the app has to maintain a constant connection to the server, which consumes about 0-1% of battery in 17h of use (on my phone). +There has been a ton of testing and improvement around this. I think it's pretty decent now. + +## Paid plans? I thought it was open source? +All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you +can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher +limits. + +## What is instant delivery? +[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the +server and listens for incoming notifications. This consumes additional battery (see above), +but delivers notifications instantly. + +## Can you implement feature X? +Yes, maybe. Check out [existing GitHub issues](https://github.com/binwiederhier/ntfy/issues) to see if somebody else had +the same idea before you, or file a new issue. I'll likely get back to you within a few days. + +## I'm having issues with iOS, can you help? The iOS app is behind compared to the Android app, can you fix that? +The iOS is very bare bones and quite frankly a little buggy. I wanted to get something out the door to make the iOS users +happy, but halfway through I got frustrated with iOS development and paused development. I will eventually get back to +it, or hopefully, somebody else will come along and help out. Please review the [known issues](known-issues.md) for details. + +## Can I disable the web app? Can I protect it with a login screen? +The web app is a static website without a backend (other than the ntfy API). All data is stored locally in the browser +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. + +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. + +## If topic names are public, could I not just brute force them? +If you don't have [ACLs set up](config.md#access-control), the topic name is your password, it says so everywhere. If you +choose a easy-to-guess/dumb topic name, people will be able to guess it. If you choose a randomly generated topic name, +the topic is as good as a good password. + +As for brute forcing: It's not possible to brute force a ntfy server for very long, as you'll get quickly rate limited. +In the default configuration, you'll be able to do 60 requests as a burst, and then 1 request per 10 seconds. Assuming you +choose a random 10 digit topic name using only A-Z, a-z, 0-9, _ and -, there are 64^10 possible topic names. Even if you +could do hundreds of requests per seconds (which you cannot), it would take many years to brute force a topic name. + +For ntfy.sh, there's even a fail2ban in place which will ban your IP pretty quickly. + +## Where can I donate? +I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier). +I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much +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. + +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 +may be able to help out. I hope you understand. diff --git a/docs/hooks.py b/docs/hooks.py new file mode 100644 index 00000000..cdb31a52 --- /dev/null +++ b/docs/hooks.py @@ -0,0 +1,6 @@ +import os +import shutil + +def copy_fonts(config, **kwargs): + site_dir = config['site_dir'] + shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get')) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..27314f1a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,95 @@ +# Getting started +ntfy lets you **send push notifications to your phone or desktop via scripts from any computer**, using simple HTTP PUT +or POST requests. I use it to notify myself when scripts fail, or long-running commands complete. + +## Step 1: Get the app + + + + +To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid. +Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just +pick a name and use it later when you [publish a message](publish.md). Note that **topic names are public, so it's wise +to choose something that cannot be guessed easily.** + +For this guide, we'll just use `mytopic` as our topic name: + +
+ ![adding a topic](static/img/getting-started-add.png){ width=500 } +
Creating/adding your first topic
+
+ +That's it. After you tap "Subscribe", the app is listening for new messages on that topic. + +## Step 2: Send a message +Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT/POST, +or with the [ntfy CLI](install.md). The message is in the request body. Here's an example showing how to publish a +simple message using a POST request: + +=== "Command line (curl)" + ``` + curl -d "Backup successful 😀" ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ``` + ntfy publish mytopic "Backup successful 😀" + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + + Backup successful 😀 + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', // PUT works too + body: 'Backup successful 😀' + }) + ``` + +=== "Go" + ``` go + http.Post("https://ntfy.sh/mytopic", "text/plain", + strings.NewReader("Backup successful 😀")) + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mytopic", + data="Backup successful 😀".encode(encoding='utf-8')) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => 'Content-Type: text/plain', + 'content' => 'Backup successful 😀' + ] + ])); + ``` + +This will create a notification that looks like this: + +
+ ![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 } +
Android notification
+
+ +That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on +[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md). + +Here's another video showing the entire process: + +
+ +
Sending push notifications to your Android phone
+
+ + diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..c1a621d7 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,613 @@ +# Installing ntfy +The `ntfy` CLI allows you to [publish messages](publish.md), [subscribe to topics](subscribe/cli.md) as well as to +self-host your own ntfy server. It's all pretty straight forward. Just install the binary, package or Docker image, +configure it and run it. Just like any other software. No fuzz. + +!!! info + The following steps are only required if you want to **self-host your own ntfy server or you want to use the ntfy CLI**. + If you just want to [send messages using ntfy.sh](publish.md), you don't need to install anything. You can just use + `curl`. + +## General steps +The ntfy server comes as a statically linked binary and is shipped as tarball, deb/rpm packages and as a Docker image. +We support amd64, armv7 and arm64. + +1. Install ntfy using one of the methods described below +2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)) +3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (for the non-root user), `~/Library/Application Support/ntfy/client.yml` (for the macOS non-root user), or `/etc/ntfy/client.yml` (for the root user), see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml)) + +To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm). +To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md) +for details). + +If you like tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU) on YouTube, or +[Alex's Docker-based setup guide](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/). Both are great +resources to get started. _I am not affiliated with Kris or Alex, I just liked their video/post._ + +## Linux binaries +Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and +deb/rpm packages. + +=== "x86_64/amd64" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_amd64.tar.gz + tar zxvf ntfy_2.7.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.7.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_amd64/{client,server}/*.yml /etc/ntfy + sudo ntfy serve + ``` + +=== "armv6" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv6.tar.gz + tar zxvf ntfy_2.7.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.7.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_armv6/{client,server}/*.yml /etc/ntfy + sudo ntfy serve + ``` + +=== "armv7/armhf" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv7.tar.gz + tar zxvf ntfy_2.7.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.7.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_armv7/{client,server}/*.yml /etc/ntfy + sudo ntfy serve + ``` + +=== "arm64" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_arm64.tar.gz + tar zxvf ntfy_2.7.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.7.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.7.0_linux_arm64/{client,server}/*.yml /etc/ntfy + sudo ntfy serve + ``` + +## Debian/Ubuntu repository +Installation via Debian repository: + +=== "x86_64/amd64" + ```bash + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg + sudo apt install apt-transport-https + sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \ + > /etc/apt/sources.list.d/archive.heckel.io.list" + sudo apt update + sudo apt install ntfy + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +=== "armv7/armhf" + ```bash + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg + sudo apt install apt-transport-https + sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \ + > /etc/apt/sources.list.d/archive.heckel.io.list" + sudo apt update + sudo apt install ntfy + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +=== "arm64" + ```bash + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg + sudo apt install apt-transport-https + sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \ + > /etc/apt/sources.list.d/archive.heckel.io.list" + sudo apt update + sudo apt install ntfy + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +Manually installing the .deb file: + +=== "x86_64/amd64" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_amd64.deb + sudo dpkg -i ntfy_*.deb + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +=== "armv6" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv6.deb + sudo dpkg -i ntfy_*.deb + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +=== "armv7/armhf" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_armv7.deb + sudo dpkg -i ntfy_*.deb + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +=== "arm64" + ```bash + wget https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.0_linux_arm64.deb + sudo dpkg -i ntfy_*.deb + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +## Fedora/RHEL/CentOS + +=== "x86_64/amd64" + ```bash + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.7.0/ntfy_2.7.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.7.0/ntfy_2.7.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.7.0/ntfy_2.7.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.7.0/ntfy_2.7.0_linux_arm64.rpm + sudo systemctl enable ntfy + sudo systemctl start ntfy + ``` + +## Arch Linux +ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). +You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, +build and install ntfy and keep it up to date. +``` +paru -S ntfysh-bin +``` + +Alternatively, run the following commands to install ntfy manually: +``` +curl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv +cd ntfysh-bin +makepkg -si +``` + +## NixOS / Nix +ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment: +``` +nix-env -iA ntfy-sh +``` + +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). + +## 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.7.0/ntfy_2.7.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.7.0/ntfy_2.7.0_darwin_all.tar.gz > ntfy_2.7.0_darwin_all.tar.gz +tar zxvf ntfy_2.7.0_darwin_all.tar.gz +sudo cp -a ntfy_2.7.0_darwin_all/ntfy /usr/local/bin/ntfy +mkdir ~/Library/Application\ Support/ntfy +cp ntfy_2.7.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +ntfy --help +``` + +!!! info + Only the ntfy CLI is supported on macOS. ntfy server is currently not supported, but you can build and run it for + development as well. Check out the [build instructions](develop.md) for details. + +## Homebrew +To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS), +simply run: +``` +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.7.0/ntfy_2.7.0_windows_amd64.zip), +extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. + +The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). + +Also available in [Scoop's](https://scoop.sh) Main repository: + +`scoop install ntfy` + +!!! 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. + +## Docker +The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should +be pretty straight forward to use. + +The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent +[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings, +you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`. + +!!! info + Note that the Docker image **does not contain a `/etc/ntfy/server.yml` file**. If you'd like to use a config file, + please manually create one outside the image and map it as a volume, e.g. via `-v /etc/ntfy:/etc/ntfy`. You may + use the [`server.yml` file on GitHub](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml) as a template. + +Basic usage (no cache or additional config): +``` +docker run -p 80:80 -it binwiederhier/ntfy serve +``` + +With persistent cache (configured as command line arguments): +```bash +docker run \ + -v /var/cache/ntfy:/var/cache/ntfy \ + -p 80:80 \ + -it \ + binwiederhier/ntfy \ + serve \ + --cache-file /var/cache/ntfy/cache.db +``` + +With other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details): +```bash +docker run \ + -v /etc/ntfy:/etc/ntfy \ + -e TZ=UTC \ + -p 80:80 \ + -u UID:GID \ + -it \ + binwiederhier/ntfy \ + serve +``` + +Using docker-compose with non-root user and healthchecks enabled: +```yaml +version: "2.3" + +services: + ntfy: + image: binwiederhier/ntfy + container_name: ntfy + command: + - serve + environment: + - TZ=UTC # optional: set desired timezone + user: UID:GID # optional: replace with your own user/group or uid/gid + volumes: + - /var/cache/ntfy:/var/cache/ntfy + - /etc/ntfy:/etc/ntfy + ports: + - 80:80 + healthcheck: # optional: remember to adapt the host:port to your environment + test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped +``` + +If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid. + +Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately. +``` +FROM binwiederhier/ntfy +COPY server.yml /etc/ntfy/server.yml +ENTRYPOINT ["ntfy", "serve"] +``` +This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port. + +## Kubernetes + +The setup for Kubernetes is very similar to that for Docker, and requires a fairly minimal deployment or pod definition to function. There +are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone +unmanned pod. + + +=== "deployment" + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: ntfy + spec: + selector: + matchLabels: + app: ntfy + template: + metadata: + labels: + app: ntfy + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy + args: ["serve"] + resources: + limits: + memory: "128Mi" + cpu: "500m" + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: config + mountPath: "/etc/ntfy" + readOnly: true + volumes: + - name: config + configMap: + name: ntfy + --- + # Basic service for port 80 + apiVersion: v1 + kind: Service + metadata: + name: ntfy + spec: + selector: + app: ntfy + ports: + - port: 80 + targetPort: 80 + ``` + +=== "stateful set" + ```yaml + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: ntfy + spec: + selector: + matchLabels: + app: ntfy + serviceName: ntfy + template: + metadata: + labels: + app: ntfy + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy + args: ["serve", "--cache-file", "/var/cache/ntfy/cache.db"] + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: config + mountPath: "/etc/ntfy" + readOnly: true + - name: cache + mountPath: "/var/cache/ntfy" + volumes: + - name: config + configMap: + name: ntfy + volumeClaimTemplates: + - metadata: + name: cache + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi + ``` + +=== "pod" + ```yaml + apiVersion: v1 + kind: Pod + metadata: + labels: + app: ntfy + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy + args: ["serve"] + resources: + limits: + memory: "128Mi" + cpu: "500m" + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: config + mountPath: "/etc/ntfy" + readOnly: true + volumes: + - name: config + configMap: + name: ntfy + ``` + +Configuration is relatively straightforward. As an example, a minimal configuration is provided. + +=== "resource definition" + ```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: ntfy + data: + server.yml: | + # Template: https://github.com/binwiederhier/ntfy/blob/main/server/server.yml + base-url: https://ntfy.sh + ``` + +=== "from-file" + ```bash + kubectl create configmap ntfy --from-file=server.yml + ``` + +## Kustomize + +ntfy can be deployed in a Kubernetes cluster with [Kustomize](https://github.com/kubernetes-sigs/kustomize), a tool used +to customize Kubernetes objects using a `kustomization.yaml` file. + +1. Create new folder - `ntfy` +2. Add all files listed below + 1. `kustomization.yaml` - stores all configmaps and resources used in a deployment + 2. `ntfy-deployment.yaml` - define deployment type and its parameters + 3. `ntfy-pvc.yaml` - describes how [persistent volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) will be created + 4. `ntfy-svc.yaml` - expose application to the internal kubernetes network + 5. `ntfy-ingress.yaml` - expose service to outside the network using [ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) + 6. `server.yaml` - simple server configuration +3. Replace **TESTNAMESPACE** within `kustomization.yaml` with designated namespace +4. Replace **ntfy.test** within `ntfy-ingress.yaml` with desired DNS name +5. Apply configuration to cluster set in current context: + +```bash +kubectl apply -k /ntfy +``` + +=== "kustomization.yaml" + ```yaml + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - ntfy-deployment.yaml # deployment definition + - ntfy-svc.yaml # service connecting pods to cluster network + - ntfy-pvc.yaml # pvc used to store cache and attachment + - ntfy-ingress.yaml # ingress definition + configMapGenerator: # will parse config from raw config to configmap,it allows for dynamic reload of application if additional app is deployed ie https://github.com/stakater/Reloader + - name: server-config + files: + - server.yml + namespace: TESTNAMESPACE # select namespace for whole application + ``` +=== "ntfy-deployment.yaml" + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: ntfy-deployment + labels: + app: ntfy-deployment + spec: + revisionHistoryLimit: 1 + replicas: 1 + selector: + matchLabels: + app: ntfy-pod + template: + metadata: + labels: + app: ntfy-pod + spec: + containers: + - name: ntfy + image: binwiederhier/ntfy:v1.28.0 # set deployed version + args: ["serve"] + env: #example of adjustments made in environmental variables + - name: TZ # set timezone + value: XXXXXXX + - name: NTFY_DEBUG # enable/disable debug + value: "false" + - name: NTFY_LOG_LEVEL # adjust log level + value: INFO + - name: NTFY_BASE_URL # add base url + value: XXXXXXXXXX + ports: + - containerPort: 80 + name: http-ntfy + resources: + limits: + memory: 300Mi + cpu: 200m + requests: + cpu: 150m + memory: 150Mi + volumeMounts: + - mountPath: /etc/ntfy/server.yml + 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 + ``` + +=== "ntfy-pvc.yaml" + ```yaml + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: ntfy-pvc + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path # adjust storage if needed + resources: + requests: + storage: 1Gi + ``` + +=== "ntfy-svc.yaml" + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: ntfy-svc + spec: + type: ClusterIP + selector: + app: ntfy-pod + ports: + - name: http-ntfy-out + protocol: TCP + port: 80 + targetPort: http-ntfy + ``` + +=== "ntfy-ingress.yaml" + ```yaml + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: ntfy-ingress + spec: + rules: + - host: ntfy.test #select own + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ntfy-svc + port: + number: 80 + ``` + +=== "server.yml" + ```yaml + cache-file: "/var/cache/ntfy/cache.db" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 00000000..a8ffa6a9 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,244 @@ +# Integrations + community projects + +There are quite a few projects that work with ntfy, integrate ntfy, or have been built around ntfy. It's super exciting to see what you guys have come up with. Feel free to [create a pull request on GitHub](https://github.com/binwiederhier/ntfy/issues) to add your own project here. + +I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community. + +## Official integrations + +- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs +- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform +- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool +- [Robusta](https://docs.robusta.dev/master/catalog/sinks/webhook.html) ⭐ - open source platform for Kubernetes troubleshooting +- [borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#third-party-monitoring-services) ⭐ - configuration-driven backup software for servers and workstations +- [Radarr](https://radarr.video/) ⭐ - Movie collection manager for Usenet and BitTorrent users +- [Sonarr](https://sonarr.tv/) ⭐ - PVR for Usenet and BitTorrent users +- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard +- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool +- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media +- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends. +- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring +- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool +- [Scrt.link](https://scrt.link/) - Share a secret +- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python +- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier +- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server +- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring +- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification + +## Integration via HTTP/SMTP/etc. + +- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr)) +- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#)) +- [Overseer](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook)) +- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook)) +- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy)) + +## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations + +- [Element](https://f-droid.org/packages/im.vector.app/) ⭐ - Matrix client +- [SchildiChat](https://schildi.chat/android/) ⭐ - Matrix client +- [Tusky](https://tusky.app/) ⭐ - Fediverse client +- [Fedilab](https://fedilab.app/) - Fediverse client +- [FindMyDevice](https://gitlab.com/Nulide/findmydevice/) - Find your Device with an SMS or online with the help of FMDServer +- [Tox Push Message App](https://github.com/zoff99/tox_push_msg_app) - Tox Push Message App + +## Libraries + +- [ntfy-php-library](https://github.com/VerifiedJoseph/ntfy-php-library) - PHP library for sending messages using a ntfy server (PHP) +- [ntfy-notifier](https://github.com/DAcodedBEAT/ntfy-notifier) - Symfony Notifier integration for ntfy (PHP) +- [ntfpy](https://github.com/Nevalicjus/ntfpy) - API Wrapper for ntfy.sh (Python) +- [pyntfy](https://github.com/DP44/pyntfy) - A module for interacting with ntfy notifications (Python) +- [vntfy](https://github.com/lmangani/vntfy) - Barebone V client for ntfy (V) +- [ntfy-middleman](https://github.com/nachotp/ntfy-middleman) - Wraps APIs and send notifications using ntfy.sh on schedule (Python) +- [ntfy-dotnet](https://github.com/nwithan8/ntfy-dotnet) - .NET client library to interact with a ntfy server (C# / .NET) +- [node-ntfy-publish](https://github.com/cityssm/node-ntfy-publish) - A Node package to publish notifications to an ntfy server (Node) +- [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R) +- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi) +- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS) +- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart) +- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go) +- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP) +- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java) + +## CLIs + GUIs + +- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events +- [ntfy Desktop client](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy +- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte +- [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 +- [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 + +## Projects + scripts + +- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust) +- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go) +- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js) +- [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) +- [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) +- [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP) +- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C) +- [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell) +- [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell) +- [website-watcher](https://github.com/muety/website-watcher) - A small tool to watch websites for changes (with XPath support) (Python) +- [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python) +- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python) +- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript) +- [ntfy Discord bot](https://github.com/binwiederhier/ntfy-bot) - ntfy Discord bot (Go) +- [ntfy Discord bot](https://github.com/jr1221/ntfy_discord_bot) - An advanced modal-based bot for interacting with the ntfy.sh API (Dart) +- [Bettarr Notifications](https://github.com/NiNiyas/Bettarr-Notifications) - Better Notifications for Sonarr and Radarr (Python) +- [Notify me the intruders](https://github.com/nothingbutlucas/notify_me_the_intruders) - Notify you if they are intruders or new connections on your network (Shell) +- [Send GitHub Action to ntfy](https://github.com/NiNiyas/ntfy-action) - Send GitHub Action workflow notifications to ntfy (JS) +- [aTable/ntfy alertmanager bridge](https://github.com/aTable/ntfy_alertmanager_bridge) - Basic alertmanager bridge to ntfy (JS) +- [~xenrox/ntfy-alertmanager](https://hub.xenrox.net/~xenrox/ntfy-alertmanager) - A bridge between ntfy and Alertmanager (Go) +- [pinpox/alertmanager-ntfy](https://github.com/pinpox/alertmanager-ntfy) - Relay prometheus alertmanager alerts to ntfy (Go) +- [alexbakker/alertmanager-ntfy](https://github.com/alexbakker/alertmanager-ntfy) - Service that forwards Prometheus Alertmanager notifications to ntfy (Go) +- [restreamchat2ntfy](https://github.com/kurohuku7/restreamchat2ntfy) - Send restream.io chat to ntfy to check on the Meta Quest (JS) +- [k8s-ntfy-deployment-service](https://github.com/Christian42/k8s-ntfy-deployment-service) - Automatic Kubernetes (k8s) ntfy deployment +- [huginn-global-entry-notif](https://github.com/kylezoa/huginn-global-entry-notif) - Checks CBP API for available appointments with Huginn (JSON) +- [ntfyer](https://github.com/KikyTokamuro/ntfyer) - Sending various information to your ntfy topic by time (TypeScript) +- [git-simple-notifier](https://github.com/plamenjm/git-simple-notifier) - Script running git-log, checking for new repositories (Shell) +- [ntfy-to-slack](https://github.com/ozskywalker/ntfy-to-slack) - Tool to subscribe to a ntfy topic and send the messages to a Slack webhook (Go) +- [ansible-ntfy](https://github.com/jpmens/ansible-ntfy) - Ansible action plugin to post JSON messages to ntfy (Python) +- [ntfy-notification-channel](https://github.com/wijourdil/ntfy-notification-channel) - Laravel Notification channel for ntfy (PHP) +- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy +- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust) +- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost +- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go) +- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell) +- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java) +- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python) +- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python) +- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums +- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows +- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog) +- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy +- [ntfy_ansible_role](https://github.com/stevenengland/ntfy_ansible_role) (on [Ansible Galaxy](https://galaxy.ansible.com/stevenengland/ntfy)) - Ansible role to install ntfy +- [easy2ntfy](https://github.com/chromoxdor/easy2ntfy) - Gateway for ESPeasy to receive commands through ntfy and using easyfetch (HTML/JS) +- [ntfy_lite](https://github.com/MPI-IS/ntfy_lite) - Minimalist python API for pushing ntfy notifications (Python) +- [notify](https://github.com/guanguans/notify) - 推送通知 (PHP) +- [zpool-events](https://github.com/maglar0/zpool-events) - Notify on ZFS pool events (Python) +- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig) +- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript) +- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS) +- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go) +- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash) +- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP) +- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager +- [NtfyMe-Blender](https://github.com/NotNanook/NtfyMe-Blender) - Blender addon to send notifications to NtfyMe (Python) +- [ntfy-ios-url-share](https://www.icloud.com/shortcuts/be8a7f49530c45f79733cfe3e41887e6) - An iOS shortcut that lets you share URLs easily and quickly. +- [ntfy-ios-filesharing](https://www.icloud.com/shortcuts/fe948d151b2e4ae08fb2f9d6b27d680b) - An iOS shortcut that lets you share files from your share feed to a topic of your choice. +- [systemd-ntfy](https://hackage.haskell.org/package/systemd-ntfy) - monitor a set of systemd services an send a notification to ntfy.sh whenever their status changes +- [RouterOS Scripts](https://git.eworm.de/cgit/routeros-scripts/about/) - a collection of scripts for MikroTik RouterOS +- [ntfy-android-builder](https://github.com/TheBlusky/ntfy-android-builder) - Script for building ntfy-android with custom Firebase configuration (Docker/Shell) + +## Blog + forum posts + +- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023 +- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023 +- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023 +- [Supercharge Your Alerts: Ntfy — The Ultimate Push Notification Solution](https://medium.com/spring-boot/supercharge-your-alerts-ntfy-the-ultimate-push-notification-solution-a3dda79651fe) - spring-boot.medium.com - 9/2023 +- [Deploy Ntfy using Docker](https://www.linkedin.com/pulse/deploy-ntfy-mohamed-sharfy/) - linkedin.com - 9/2023 +- [Send Notifications With Ntfy for New WordPress Posts](https://www.activepieces.com/blog/ntfy-notifications-for-wordpress-new-posts) - activepieces.com - 9/2023 +- [Get Ntfy Notifications About New Zendesk Ticket](https://www.activepieces.com/blog/ntfy-notifications-about-new-zendesk-tickets) - activepieces.com - 9/2023 +- [Set reminder for recurring events using ntfy & Cron](https://www.youtube.com/watch?v=J3O4aQ-EcYk) - youtube.com - 9/2023 +- [ntfy - Installation and full configuration setup](https://www.youtube.com/watch?v=QMy14rGmpFI) - youtube.com - 9/2023 +- [How to install Ntfy.sh on Portainer / Docker Compose](https://www.youtube.com/watch?v=utD9GNbAwyg) - youtube.com - 9/2023 +- [ntfy - Push-Benachrichtigungen // Push Notifications](https://www.youtube.com/watch?v=LE3vRPPqZOU) - youtube.com - 9/2023 +- [Podman Update Notifications via Ntfy](https://rair.dev/podman-upadte-notifications-ntfy/) - rair.dev - 9/2023 +- [NetworkChunk - how did I NOT know about this?](https://www.youtube.com/watch?v=poDIT2ruQ9M) ⭐ - youtube.com - 8/2023 +- [NTFY - Command-Line Notifications](https://academy.networkchuck.com/blog/ntfy/) - academy.networkchuck.com - 8/2023 +- [Open Source Push Notifications! Get notified of any event you can imagine. Triggers abound!](https://www.youtube.com/watch?v=WJgwWXt79pE) ⭐ - youtube.com - 8/2023 +- [How to install and self host an Ntfy server on Linux](https://linuxconfig.org/how-to-install-and-self-host-an-ntfy-server-on-linux) - linuxconfig.org - 7/2023 +- [Basic website monitoring using cronjobs and ntfy.sh](https://burkhardt.dev/2023/website-monitoring-cron-ntfy/) - burkhardt.dev - 6/2023 +- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023 +- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023 +- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023 +- [桌面通知:ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023 +- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023 +- [Install ntfy Inside Docker Container in Linux](https://lindevs.com/install-ntfy-inside-docker-container-in-linux) - lindevs.com - 4/2023 +- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023 +- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023 +- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023 +- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023 +- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023 +- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023 +- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023 +- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023 +- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023 +- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023 +- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023 +- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023 +- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023 +- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022 +- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022 +- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022 +- [NTFY - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 12/2022 +- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022 +- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022 +- [Enabling SSH Login Notifications using Ntfy](https://paramdeo.com/blog/enabling-ssh-login-notifications-using-ntfy) - paramdeo.com - 11/2022 +- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022 +- [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022 +- [How to make my phone buzz*](https://evbogue.com/howtomakemyphonebuzz) - evbogue.com - 11/2022 +- [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022 +- [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022 +- [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022 +- [Envie Push Notifications por POST (de graça e sem cadastro)](https://www.tabnews.com.br/filipedeschamps/envie-push-notifications-por-post-de-graca-e-sem-cadastro) - tabnews.com.br - 11/2022 +- [Push Notifications for KDE](https://volkerkrause.eu/2022/11/12/kde-unifiedpush-push-notifications.html) - volkerkrause.eu - 11/2022 +- [TLDR Newsletter Daily Update 2022-11-09](https://tldr.tech/tech/newsletter/2022-11-09) ⭐ - tldr.tech - 11/2022 +- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022 +- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022 +- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022 +- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022 +- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022 +- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022 +- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022 +- [Neue Services: Ntfy, TikTok und RustDesk](https://adminforge.de/tools/neue-services-ntfy-tiktok-und-rustdesk/) - adminforge.de - 9/2022 +- [Ntfy, le service de notifications qu’il vous faut](https://www.cachem.fr/ntfy-le-service-de-notifications-quil-vous-faut/) - cachem.fr - 9/2022 +- [NAS Synology et notifications avec ntfy](https://www.cachem.fr/synology-notifications-ntfy/) - cachem.fr - 9/2022 +- [Self hosted Mobile Push Notifications using NTFY | Thejesh GN](https://thejeshgn.com/2022/08/23/self-hosted-mobile-push-notifications-using-ntfy/) - thejeshgn.com - 8/2022 +- [Fedora Magazine | 4 cool new projects to try in Copr](https://fedoramagazine.org/4-cool-new-projects-to-try-in-copr-for-august-2022/) - fedoramagazine.org - 8/2022 +- [Docker로 오픈소스 푸시알람 프로젝트 ntfy.sh 설치 및 사용하기.(Feat. Uptimekuma)](https://svrforum.com/svr/398979) - svrforum.com - 8/2022 +- [Easy notifications from R](https://sometimesir.com/posts/easy-notifications-from-r/) - sometimesir.com - 6/2022 +- [ntfy is finally coming to iOS, and Matrix/UnifiedPush gateway support](https://www.reddit.com/r/selfhosted/comments/vdzvxi/ntfy_is_finally_coming_to_ios_with_full/) ⭐ - reddit.com - 6/2022 +- [Install guide (with Docker)](https://chowdera.com/2022/150/202205301257379077.html) - chowdera.com - 5/2022 +- [无需注册的通知服务ntfy](https://blog.csdn.net/wbsu2004/article/details/125040247) - blog.csdn.net - 5/2022 +- [Updated review post (Jan-Lukas Else)](https://jlelse.blog/thoughts/2022/04/ntfy) - jlelse.blog - 4/2022 +- [Using ntfy and Tasker together](https://lachlanlife.net/posts/2022-04-tasker-ntfy/) - lachlanlife.net - 4/2022 +- [Reddit feature update post](https://www.reddit.com/r/selfhosted/comments/uetlso/ntfy_is_a_tool_to_send_push_notifications_to_your/) ⭐ - reddit.com - 4/2022 +- [無料で簡単に通知の送受信ができつつオープンソースでセルフホストも可能な「ntfy」を使ってみた](https://gigazine.net/news/20220404-ntfy-push-notification/) - gigazine.net - 4/2022 +- [Pocketmags ntfy review](https://pocketmags.com/us/linux-format-magazine/march-2022/articles/1104187/ntfy) - pocketmags.com - 3/2022 +- [Reddit web app release post](https://www.reddit.com/r/selfhosted/comments/tc0p0u/say_hello_to_the_brand_new_ntfysh_web_app_push/) ⭐ - reddit.com- 3/2022 +- [Lemmy post (Jakob)](https://lemmy.eus/post/15541) - lemmy.eus - 1/2022 +- [Reddit UnifiedPush release post](https://www.reddit.com/r/selfhosted/comments/s5jylf/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 1/2022 +- [ntfy: send notifications from your computer to your phone](https://rs1.es/tutorials/2022/01/19/ntfy-send-notifications-phone.html) - rs1.es - 1/2022 +- [Short ntfy review (Jan-Lukas Else)](https://jlelse.blog/links/2021/12/ntfy-sh) - jlelse.blog - 12/2021 +- [Free MacroDroid webhook alternative (FrameXX)](https://www.macrodroidforum.com/index.php?threads/ntfy-sh-free-macrodroid-webhook-alternative.1505/) - macrodroidforum.com - 12/2021 +- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021 +- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021 +- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021 + + +## Alternative ntfy servers + +Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the +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.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland | +| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany | +| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | +| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany | +| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France | +| [ntfy.fossman.de](https://ntfy.fossman.de/) | 🇩🇪 Germany | + +Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability +and uptime of third party servers, so use of each server is **at your own discretion**. diff --git a/docs/known-issues.md b/docs/known-issues.md new file mode 100644 index 00000000..cdb95bb6 --- /dev/null +++ b/docs/known-issues.md @@ -0,0 +1,43 @@ +# Known issues +This is an incomplete list of known issues with the ntfy server, web app, Android app, and iOS app. You can find a complete +list [on GitHub](https://github.com/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug), but I thought it may be helpful +to have the prominent ones here to link to. + +## iOS app not refreshing (see [#267](https://github.com/binwiederhier/ntfy/issues/267)) +For some (many?) users, the iOS app is not refreshing the view when new notifications come in. Until you manually +swipe down, you do not see the newly arrived messages, even though the popup appeared before. + +This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely +clueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it. + +Please send experienced iOS developers my way to help me figure this out. + +## iOS app not receiving notifications (anymore) +If notifications do not show up at all anymore, there are a few causes for it (that I know of): + +**Firebase+APNS are being weird and buggy**: +If this is the case, usually it helps to **remove the topic/subscription and re-add it**. That will force Firebase to +re-subscribe to the Firebase topic. + +**Self-hosted only: No `upstream-base-url` set, or `base-url` mismatch**: +To make self-hosted servers work with the iOS +app, I had to do some horrible things (see [iOS instant notifications](config.md#ios-instant-notifications) for details). +Be sure that in your selfhosted server: + +* Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**) +* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to + +## iOS app seeing "New message", but not real message content +If you see `New message` notifications on iOS, your iPhone can likely not talk to your self-hosted server. Be sure that +your iOS device and your ntfy server are either on the same network, or that your phone can actually reach the server. + +Turn on tracing/debugging on the server (via `log-level: trace` or `log-level: debug`, see [troubleshooting](troubleshooting.md)), +and read docs on [iOS instant notifications](https://docs.ntfy.sh/config/#ios-instant-notifications). + +## Safari does not play sounds for web push notifications +Safari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with +iOS 17 / Safari 17, which will be released later in 2023. + +## PWA on iOS sometimes crashes with an IndexedDB error (see [#787](https://github.com/binwiederhier/ntfy/issues/787)) +When resuming the installed PWA from the background, it sometimes crashes with an error from IndexedDB/Dexie, due to a +[WebKit bug]( https://bugs.webkit.org/show_bug.cgi?id=197050). A reload will fix it until a permanent fix is found. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 00000000..f89f9aaa --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,12 @@ +# 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.** + +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. + +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. diff --git a/docs/publish.md b/docs/publish.md new file mode 100644 index 00000000..41370778 --- /dev/null +++ b/docs/publish.md @@ -0,0 +1,3611 @@ +# 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. + +Here's an example showing how to publish a simple message using a POST request: + +=== "Command line (curl)" + ``` + curl -d "Backup successful 😀" ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ``` + ntfy publish mytopic "Backup successful 😀" + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + + Backup successful 😀 + ``` +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', // PUT works too + body: 'Backup successful 😀' + }) + ``` + +=== "Go" + ``` go + http.Post("https://ntfy.sh/mytopic", "text/plain", + strings.NewReader("Backup successful 😀")) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Body = "Backup successful" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mytopic", + data="Backup successful 😀".encode(encoding='utf-8')) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => 'Content-Type: text/plain', + 'content' => 'Backup successful 😀' + ] + ])); + ``` + +If you have the [Android app](subscribe/phone.md) installed on your phone, this will create a notification that looks like this: + +
+ ![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 } +
Android notification
+
+ +There are more features related to publishing messages: You can set a [notification priority](#message-priority), +a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an example that uses some of them at together: + +=== "Command line (curl)" + ``` + curl \ + -H "Title: Unauthorized access detected" \ + -H "Priority: urgent" \ + -H "Tags: warning,skull" \ + -d "Remote access to phils-laptop detected. Act right away." \ + ntfy.sh/phil_alerts + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --title "Unauthorized access detected" \ + --tags warning,skull \ + --priority urgent \ + mytopic \ + "Remote access to phils-laptop detected. Act right away." + ``` + +=== "HTTP" + ``` http + POST /phil_alerts HTTP/1.1 + Host: ntfy.sh + Title: Unauthorized access detected + Priority: urgent + Tags: warning,skull + + Remote access to phils-laptop detected. Act right away. + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/phil_alerts', { + method: 'POST', // PUT works too + body: 'Remote access to phils-laptop detected. Act right away.', + headers: { + 'Title': 'Unauthorized access detected', + 'Priority': 'urgent', + 'Tags': 'warning,skull' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/phil_alerts", + strings.NewReader("Remote access to phils-laptop detected. Act right away.")) + req.Header.Set("Title", "Unauthorized access detected") + req.Header.Set("Priority", "urgent") + req.Header.Set("Tags", "warning,skull") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/phil_alerts" + Headers = @{ + Title = "Unauthorized access detected" + Priority = "urgent" + Tags = "warning,skull" + } + Body = "Remote access to phils-laptop detected. Act right away." + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/phil_alerts", + data="Remote access to phils-laptop detected. Act right away.", + headers={ + "Title": "Unauthorized access detected", + "Priority": "urgent", + "Tags": "warning,skull" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + "Content-Type: text/plain\r\n" . + "Title: Unauthorized access detected\r\n" . + "Priority: urgent\r\n" . + "Tags: warning,skull", + 'content' => 'Remote access to phils-laptop detected. Act right away.' + ] + ])); + ``` + +
+ ![priority notification](static/img/priority-notification.png){ width=500 } +
Urgent notification with tags and title
+
+ +You can also do multi-line messages. Here's an example using a [click action](#click-action), an [action button](#action-buttons), +an [external image attachment](#attach-file-from-a-url) and [email publishing](#e-mail-publishing): + +=== "Command line (curl)" + ``` + curl \ + -H "Click: https://home.nest.com/" \ + -H "Attach: https://nest.com/view/yAxkasd.jpg" \ + -H "Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true" \ + -H "Email: phil@example.com" \ + -d "There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell." \ + ntfy.sh/mydoorbell + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --click="https://home.nest.com/" \ + --attach="https://nest.com/view/yAxkasd.jpg" \ + --actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true" \ + --email="phil@example.com" \ + mydoorbell \ + "There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell." + ``` + +=== "HTTP" + ``` http + POST /mydoorbell HTTP/1.1 + Host: ntfy.sh + Click: https://home.nest.com/ + Attach: https://nest.com/view/yAxkasd.jpg + Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true + Email: phil@example.com + + There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell. + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mydoorbell', { + method: 'POST', // PUT works too + headers: { + 'Click': 'https://home.nest.com/', + 'Attach': 'https://nest.com/view/yAxkasd.jpg', + 'Actions': 'http, Open door, https://api.nest.com/open/yAxkasd, clear=true', + 'Email': 'phil@example.com' + }, + body: `There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell.`, + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mydoorbell", + strings.NewReader(`There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell.`)) + req.Header.Set("Click", "https://home.nest.com/") + req.Header.Set("Attach", "https://nest.com/view/yAxkasd.jpg") + req.Header.Set("Actions", "http, Open door, https://api.nest.com/open/yAxkasd, clear=true") + req.Header.Set("Email", "phil@example.com") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mydoorbell" + Headers = @{ + Click = "https://home.nest.com" + Attach = "https://nest.com/view/yAxksd.jpg" + Actions = "http, Open door, https://api.nest.com/open/yAxkasd, clear=true" + Email = "phil@example.com" + } + Body = "There's someone at the door. 🐶`n + `n + Please check if it's a good boy or a hooman.`n + Doggies have been known to ring the doorbell.`n" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mydoorbell", + data="""There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell.""".encode('utf-8'), + headers={ + "Click": "https://home.nest.com/", + "Attach": "https://nest.com/view/yAxkasd.jpg", + "Actions": "http, Open door, https://api.nest.com/open/yAxkasd, clear=true", + "Email": "phil@example.com" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mydoorbell', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + "Content-Type: text/plain\r\n" . + "Click: https://home.nest.com/\r\n" . + "Attach: https://nest.com/view/yAxkasd.jpg\r\n" . + "Actions": "http, Open door, https://api.nest.com/open/yAxkasd, clear=true\r\n" . + "Email": "phil@example.com\r\n", + 'content' => 'There\'s someone at the door. 🐶 + + Please check if it\'s a good boy or a hooman. + Doggies have been known to ring the doorbell.' + ] + ])); + ``` + +
+ ![priority notification](static/img/android-screenshot-notification-multiline.jpg){ width=500 } +
Notification using a click action, a user action, with an external image attachment and forwarded via email
+
+ +## Message title +_Supported on:_ :material-android: :material-apple: :material-firefox: + +The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title, +you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`). + +=== "Command line (curl)" + ``` + curl -H "X-Title: Dogs are better than cats" -d "Oh my ..." ntfy.sh/controversial + curl -H "Title: Dogs are better than cats" -d "Oh my ..." ntfy.sh/controversial + curl -H "t: Dogs are better than cats" -d "Oh my ..." ntfy.sh/controversial + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -t "Dogs are better than cats" \ + controversial "Oh my ..." + ``` + +=== "HTTP" + ``` http + POST /controversial HTTP/1.1 + Host: ntfy.sh + Title: Dogs are better than cats + + Oh my ... + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/controversial', { + method: 'POST', + body: 'Oh my ...', + headers: { 'Title': 'Dogs are better than cats' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/controversial", strings.NewReader("Oh my ...")) + req.Header.Set("Title", "Dogs are better than cats") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/controversial" + Headers = @{ + Title = "Dogs are better than cats" + } + Body = "Oh my ..." + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/controversial", + data="Oh my ...", + headers={ "Title": "Dogs are better than cats" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/controversial', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Title: Dogs are better than cats", + 'content' => 'Oh my ...' + ] + ])); + ``` + +
+ ![notification with title](static/img/notification-with-title.png){ width=500 } +
Detail view of notification with title
+
+ +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title) + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + +## Message priority +_Supported on:_ :material-android: :material-apple: :material-firefox: + +All messages have a priority, which defines how urgently your phone notifies you. On Android, you can set custom +notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)). + +The following priorities exist: + +| Priority | Icon | ID | Name | Description | +|----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------| +| Max priority | ![min priority](static/img/priority-5.svg) | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. | +| High priority | ![min priority](static/img/priority-4.svg) | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. | +| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. | +| Low priority | ![min priority](static/img/priority-2.svg) | `2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. | +| Min priority | ![min priority](static/img/priority-1.svg) | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". | + +You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`). + +=== "Command line (curl)" + ``` + curl -H "X-Priority: 5" -d "An urgent message" ntfy.sh/phil_alerts + curl -H "Priority: low" -d "Low priority message" ntfy.sh/phil_alerts + curl -H p:4 -d "A high priority message" ntfy.sh/phil_alerts + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -p 5 \ + phil_alerts An urgent message + ``` + +=== "HTTP" + ``` http + POST /phil_alerts HTTP/1.1 + Host: ntfy.sh + Priority: 5 + + An urgent message + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/phil_alerts', { + method: 'POST', + body: 'An urgent message', + headers: { 'Priority': '5' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/phil_alerts", strings.NewReader("An urgent message")) + req.Header.Set("Priority", "5") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = 'POST' + URI = "https://ntfy.sh/phil_alerts" + Headers = @{ + Priority = "5" + } + Body = "An urgent message" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/phil_alerts", + data="An urgent message", + headers={ "Priority": "5" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Priority: 5", + 'content' => 'An urgent message' + ] + ])); + ``` + +
+ ![priority notification](static/img/priority-detail-overview.png){ width=500 } +
Detail view of priority notifications
+
+ +## Tags & emojis 🥳 🎉 +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can tag messages with emojis and other relevant strings: + +* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended + to title or message. +* **Other tags:** If a tag doesn't match, it will be listed below the notification. + +This feature is useful for things like warnings (⚠️, ️🚨, or 🚩), but also to simply tag messages otherwise (e.g. script +names, hostnames, etc.). Use [the emoji short code list](emojis.md) to figure out what tags can be converted to emojis. +Here's an **excerpt of emojis** I've found very useful in alert messages: + + + + + +
+ + + + + + + +
TagEmoji
+1👍
partying_face🥳
tada🎉
heavy_check_mark✔️
loudspeaker📢
......
+
+ + + + + + + +
TagEmoji
-1👎️
warning⚠️
rotating_light️🚨
triangular_flag_on_post🚩
skull💀
......
+
+ + + + + + + +
TagEmoji
facepalm🤦
no_entry
no_entry_sign🚫
cd💿
computer💻
......
+
+ +You can set tags with the `X-Tags` header (or any of its aliases: `Tags`, `tag`, or `ta`). Specify multiple tags by separating +them with a comma, e.g. `tag1,tag2,tag3`. + +=== "Command line (curl)" + ``` + curl -H "X-Tags: warning,mailsrv13,daily-backup" -d "Backup of mailsrv13 failed" ntfy.sh/backups + curl -H "Tags: horse,unicorn" -d "Unicorns are just horses with unique horns" ntfy.sh/backups + curl -H ta:dog -d "Dogs are awesome" ntfy.sh/backups + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --tags=warning,mailsrv13,daily-backup \ + backups "Backup of mailsrv13 failed" + ``` + +=== "HTTP" + ``` http + POST /backups HTTP/1.1 + Host: ntfy.sh + Tags: warning,mailsrv13,daily-backup + + Backup of mailsrv13 failed + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/backups', { + method: 'POST', + body: 'Backup of mailsrv13 failed', + headers: { 'Tags': 'warning,mailsrv13,daily-backup' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/backups", strings.NewReader("Backup of mailsrv13 failed")) + req.Header.Set("Tags", "warning,mailsrv13,daily-backup") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/backups" + Headers = @{ + Tags = "warning,mailsrv13,daily-backup" + } + Body = "Backup of mailsrv13 failed" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/backups", + data="Backup of mailsrv13 failed", + headers={ "Tags": "warning,mailsrv13,daily-backup" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/backups', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Tags: warning,mailsrv13,daily-backup", + 'content' => 'Backup of mailsrv13 failed' + ] + ])); + ``` + +
+ ![priority notification](static/img/notification-with-tags.png){ width=500 } +
Detail view of notifications with tags
+
+ +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + +## Markdown formatting +_Supported on:_ :material-firefox: + +You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use +**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now): + +- [Emphasis](https://www.markdownguide.org/basic-syntax/#emphasis) such as **bold** (`**bold**`), *italics* (`*italics*`) +- [Links](https://www.markdownguide.org/basic-syntax/#links) (`[some tool](https://ntfy.sh)`) +- [Images](https://www.markdownguide.org/basic-syntax/#images) (`![some image](https://bing.com/logo.png)`) +- [Code blocks](https://www.markdownguide.org/basic-syntax/#code-blocks) (` ```code blocks``` `) and [inline code](https://www.markdownguide.org/basic-syntax/#inline-code) (`` `inline code` ``) +- [Headings](https://www.markdownguide.org/basic-syntax/#headings) (`# headings`, `## headings`, etc.) +- [Lists](https://www.markdownguide.org/basic-syntax/#lists) (`- lists`, `1. lists`, etc.) +- [Blockquotes](https://www.markdownguide.org/basic-syntax/#blockquotes) (`> blockquotes`) +- [Horizontal rules](https://www.markdownguide.org/basic-syntax/#horizontal-rules) (`---`) + +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: + +=== "Command line (curl)" + ``` + curl \ + -d "Look ma, **bold text**, *italics*, ..." \ + -H "Markdown: yes" \ + ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --markdown \ + mytopic \ + "Look ma, **bold text**, *italics*, ..." + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + Markdown: yes + + Look ma, **bold text**, *italics*, ... + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', // PUT works too + body: 'Look ma, **bold text**, *italics*, ...', + headers: { 'Markdown': 'yes' } + }) + ``` + +=== "Go" + ``` go + http.Post("https://ntfy.sh/mytopic", "text/markdown", + strings.NewReader("Look ma, **bold text**, *italics*, ...")) + + // or + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Look ma, **bold text**, *italics*, ...")) + req.Header.Set("Markdown", "yes") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Body = "Look ma, **bold text**, *italics*, ..." + Headers = @{ + Markdown = "yes" + } + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mytopic", + data="Look ma, **bold text**, *italics*, ..." + headers={ "Markdown": "yes" })) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => 'Content-Type: text/markdown', // ! + 'content' => 'Look ma, **bold text**, *italics*, ...' + ] + ])); + ``` + +Here's what that looks like in the web app: + +
+ ![markdown](static/img/web-markdown.png){ width=500 } +
Markdown formatting in the web app
+
+ +## Scheduled delivery +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself +reminders or even to execute commands at a later date (if your subscriber acts on messages). + +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)). + +As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can currently +not be configured otherwise ([let me know](https://github.com/binwiederhier/ntfy/issues) if you'd like to change +these limits). + +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. + +=== "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 + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --at="tomorrow, 10am" \ + hello "Good morning" + ``` + +=== "HTTP" + ``` http + POST /hello HTTP/1.1 + Host: ntfy.sh + At: tomorrow, 10am + + Good morning + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/hello', { + method: 'POST', + body: 'Good morning', + headers: { 'At': 'tomorrow, 10am' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning")) + req.Header.Set("At", "tomorrow, 10am") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/hello" + Headers = @{ + At = "tomorrow, 10am" + } + Body = "Good morning" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/hello", + data="Good morning", + headers={ "At": "tomorrow, 10am" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/backups', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "At: tomorrow, 10am", + 'content' => 'Good morning' + ] + ])); + ``` + +Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**): + + + +
+ + + + + + + +
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)
+
+ +## 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 (e.g. +like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app). + +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): + +=== "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" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -p 5 --tags=warning,skull \ + mywebhook "Webhook triggered" + ``` + +=== "HTTP" + ``` http + GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull') + ``` + +=== "Go" + ``` go + http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + ``` + +=== "Python" + ``` python + requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); + ``` + +## 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)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` 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" + } + 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" }] + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', 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" ]] + ]) + ] + ])); + ``` + +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-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) | + +## Action buttons +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly +useful and has countless applications. + +You can control your home appliances (open/close garage door, change temperature on thermostat, ...), react to common +monitoring alerts (clear logs when disk is full, ...), and many other things. The sky is the limit. + +As of today, the following actions are supported: + +* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped +* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent + 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 + +Here's an example of what a notification with actions can look like: + +
+ ![notification with actions](static/img/android-screenshot-notification-actions.png){ width=500 } +
Notification with two user actions
+
+ +### Defining actions +You can define **up to three user actions** in your notifications, using either of the following methods: + +* In the [`X-Actions` header](#using-a-header), using a simple comma-separated format +* As a [JSON array](#using-a-json-array) in the `actions` key, when [publishing as JSON](#publish-as-json) + +#### Using a header +To define actions using the `X-Actions` header (or any of its aliases: `Actions`, `Action`), use the following format: + +=== "Header format (long)" + ``` + action=, label=, paramN=... [; action=, label=, ...] + ``` + +=== "Header format (short)" + ``` + , , paramN=... [; , , ...] + ``` + +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. + +As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and +[`http` action](#send-http-request) section for details on the specific actions: + +=== "Command line (curl)" + ``` + body='{"temperature": 65}' + curl \ + -d "You left the house. Turn down the A/C?" \ + -H "Actions: view, Open portal, https://home.nest.com/, clear=true; \ + http, Turn down, https://api.nest.com/, body='$body'" \ + ntfy.sh/myhome + ``` + +=== "ntfy CLI" + ``` + body='{"temperature": 65}' + ntfy publish \ + --actions="view, Open portal, https://home.nest.com/, clear=true; \ + http, Turn down, https://api.nest.com/, body='$body'" \ + myhome \ + "You left the house. Turn down the A/C?" + ``` + +=== "HTTP" + ``` http + POST /myhome HTTP/1.1 + Host: ntfy.sh + Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{"temperature": 65}' + + You left the house. Turn down the A/C? + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/myhome', { + method: 'POST', + body: 'You left the house. Turn down the A/C?', + headers: { + 'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body=\'{"temperature": 65}\'' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?")) + req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/myhome" + Headers = @{ + Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" + } + Body = "You left the house. Turn down the A/C?" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/myhome", + data="You left the house. Turn down the A/C?", + headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'", + 'content' => 'You left the house. Turn down the A/C?' + ] + ])); + ``` + +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions) + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + +#### Using a JSON array +Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body +(see [publish as JSON](#publish-as-json)): + +=== "Command line (curl)" + ``` + curl ntfy.sh \ + -d '{ + "topic": "myhome", + "message": "You left the house. Turn down the A/C?", + "actions": [ + { + "action": "view", + "label": "Open portal", + "url": "https://home.nest.com/", + "clear": true + }, + { + "action": "http", + "label": "Turn down", + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" + } + ] + }' + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions '[ + { + "action": "view", + "label": "Open portal", + "url": "https://home.nest.com/", + "clear": true + }, + { + "action": "http", + "label": "Turn down", + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" + } + ]' \ + myhome \ + "You left the house. Turn down the A/C?" + ``` + +=== "HTTP" + ``` http + POST / HTTP/1.1 + Host: ntfy.sh + + { + "topic": "myhome", + "message": "You left the house. Turn down the A/C?", + "actions": [ + { + "action": "view", + "label": "Open portal", + "url": "https://home.nest.com/", + "clear": true + }, + { + "action": "http", + "label": "Turn down", + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" + } + ] + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh', { + method: 'POST', + body: JSON.stringify({ + topic: "myhome", + message: "You left the house. Turn down the A/C?", + actions: [ + { + action: "view", + label: "Open portal", + url: "https://home.nest.com/", + clear: true + }, + { + action: "http", + label: "Turn down", + url: "https://api.nest.com/", + body: "{\"temperature\": 65}" + } + ] + }) + }) + ``` + +=== "Go" + ``` go + // You should probably use json.Marshal() instead and make a proper struct, + // but for the sake of the example, this is easier. + + body := `{ + "topic": "myhome", + "message": "You left the house. Turn down the A/C?", + "actions": [ + { + "action": "view", + "label": "Open portal", + "url": "https://home.nest.com/", + "clear": true + }, + { + "action": "http", + "label": "Turn down", + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" + } + ] + }` + req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-JSON @{ + Topic = "myhome" + Message = "You left the house. Turn down the A/C?" + Actions = @( + @{ + Action = "view" + Label = "Open portal" + URL = "https://home.nest.com/" + Clear = $true + }, + @{ + Action = "http" + Label = "Turn down" + URL = "https://api.nest.com/" + Body = '{"temperature": 65}' + } + ) + } + ContentType = "application/json" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/", + data=json.dumps({ + "topic": "myhome", + "message": "You left the house. Turn down the A/C?", + "actions": [ + { + "action": "view", + "label": "Open portal", + "url": "https://home.nest.com/", + "clear": true + }, + { + "action": "http", + "label": "Turn down", + "url": "https://api.nest.com/", + "body": "{\"temperature\": 65}" + } + ] + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => json_encode([ + "topic": "myhome", + "message": "You left the house. Turn down the A/C?", + "actions": [ + [ + "action": "view", + "label": "Open portal", + "url": "https://home.nest.com/", + "clear": true + ], + [ + "action": "http", + "label": "Turn down", + "url": "https://api.nest.com/", + "headers": [ + "Authorization": "Bearer ..." + ], + "body": "{\"temperature\": 65}" + ] + ] + ]) + ] + ])); + ``` + +The required/optional fields for each action depend on the type of the action itself. Please refer to +[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request) +for details. + +### Open website/app +_Supported on:_ :material-android: :material-apple: :material-firefox: + +The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or +even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your +desktop browser treat the links. Normally it'll just open a link in the browser. + +Examples: + +* `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 using the [`X-Actions` header](#using-a-header): + +=== "Command line (curl)" + ``` + curl \ + -d "Somebody retweeted your tweet." \ + -H "Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \ + ntfy.sh/myhome + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \ + myhome \ + "Somebody retweeted your tweet." + ``` + +=== "HTTP" + ``` http + POST /myhome HTTP/1.1 + Host: ntfy.sh + Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392 + + Somebody retweeted your tweet. + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/myhome', { + method: 'POST', + body: 'Somebody retweeted your tweet.', + headers: { + 'Actions': 'view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Somebody retweeted your tweet.")) + req.Header.Set("Actions", "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/myhome" + Headers = @{ + Actions = "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" + } + Body = "Somebody retweeted your tweet." + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/myhome", + data="Somebody retweeted your tweet.", + headers={ "Actions": "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392", + 'content' => 'Somebody retweeted your tweet.' + ] + ])); + ``` + +And the same example using [JSON publishing](#publish-as-json): + +=== "Command line (curl)" + ``` + curl ntfy.sh \ + -d '{ + "topic": "myhome", + "message": "Somebody retweeted your tweet.", + "actions": [ + { + "action": "view", + "label": "Open Twitter", + "url": "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ] + }' + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions '[ + { + "action": "view", + "label": "Open Twitter", + "url": "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ]' \ + myhome \ + "Somebody retweeted your tweet." + ``` + +=== "HTTP" + ``` http + POST / HTTP/1.1 + Host: ntfy.sh + + { + "topic": "myhome", + "message": "Somebody retweeted your tweet.", + "actions": [ + { + "action": "view", + "label": "Open Twitter", + "url": "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ] + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh', { + method: 'POST', + body: JSON.stringify({ + topic: "myhome", + message": "Somebody retweeted your tweet.", + actions: [ + { + action: "view", + label: "Open Twitter", + url: "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ] + }) + }) + ``` + +=== "Go" + ``` go + // You should probably use json.Marshal() instead and make a proper struct, + // but for the sake of the example, this is easier. + + body := `{ + "topic": "myhome", + "message": "Somebody retweeted your tweet.", + "actions": [ + { + "action": "view", + "label": "Open Twitter", + "url": "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ] + }` + req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-JSON @{ + Topic = "myhome" + Message = "Somebody retweeted your tweet." + Actions = @( + @{ + Action = "view" + Label = "Open Twitter" + URL = "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ) + } + ContentType = "application/json" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/", + data=json.dumps({ + "topic": "myhome", + "message": "Somebody retweeted your tweet.", + "actions": [ + { + "action": "view", + "label": "Open Twitter", + "url": "https://twitter.com/binwiederhier/status/1467633927951163392" + } + ] + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => json_encode([ + "topic": "myhome", + "message": "Somebody retweeted your tweet.", + "actions": [ + [ + "action": "view", + "label": "Open Twitter", + "url": "https://twitter.com/binwiederhier/status/1467633927951163392" + ] + ] + ]) + ] + ])); + ``` + +The `view` action supports the following fields: + +| Field | Required | Type | Default | Example | Description | +|----------|----------|-----------|---------|-----------------------|--------------------------------------------------| +| `action` | ✔️ | *string* | - | `view` | Action type (**must be `view`**) | +| `label` | ✔️ | *string* | - | `Turn on light` | Label of the action button in the notification | +| `url` | ✔️ | *URL* | - | `https://example.com` | URL to open when action is tapped | +| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped | + +### Send Android broadcast +_Supported on:_ :material-android: + +The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent +when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) +or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means +you can do everything your phone is capable of. Examples include taking pictures, launching/killing apps, change device +settings, write/read files, etc. + +By default, the intent action **`io.heckel.ntfy.USER_ACTION`** is broadcast, though this can be changed with the `intent` parameter (see below). +To send extras, use the `extras` parameter. Currently, **only string extras are supported**. + +!!! info + If you have no idea what this is, check out the [automation apps](subscribe/phone.md#automation-apps) section, which shows + how to integrate Tasker and MacroDroid **with screenshots**. The action button integration is identical, except that + you have to use **the intent action `io.heckel.ntfy.USER_ACTION`** instead. + +Here's an example using the [`X-Actions` header](#using-a-header): + +=== "Command line (curl)" + ``` + curl \ + -d "Your wife requested you send a picture of yourself." \ + -H "Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front" \ + ntfy.sh/wifey + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions="broadcast, Take picture, extras.cmd=pic, extras.camera=front" \ + wifey \ + "Your wife requested you send a picture of yourself." + ``` + +=== "HTTP" + ``` http + POST /wifey HTTP/1.1 + Host: ntfy.sh + Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front + + Your wife requested you send a picture of yourself. + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/wifey', { + method: 'POST', + body: 'Your wife requested you send a picture of yourself.', + headers: { + 'Actions': 'broadcast, Take picture, extras.cmd=pic, extras.camera=front' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/wifey", strings.NewReader("Your wife requested you send a picture of yourself.")) + req.Header.Set("Actions", "broadcast, Take picture, extras.cmd=pic, extras.camera=front") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/wifey" + Headers = @{ + Actions = "broadcast, Take picture, extras.cmd=pic, extras.camera=front" + } + Body = "Your wife requested you send a picture of yourself." + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/wifey", + data="Your wife requested you send a picture of yourself.", + headers={ "Actions": "broadcast, Take picture, extras.cmd=pic, extras.camera=front" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/wifey', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Actions: broadcast, Take picture, extras.cmd=pic, extras.camera=front", + 'content' => 'Your wife requested you send a picture of yourself.' + ] + ])); + ``` + +And the same example using [JSON publishing](#publish-as-json): + +=== "Command line (curl)" + ``` + curl ntfy.sh \ + -d '{ + "topic": "wifey", + "message": "Your wife requested you send a picture of yourself.", + "actions": [ + { + "action": "broadcast", + "label": "Take picture", + "extras": { + "cmd": "pic", + "camera": "front" + } + } + ] + }' + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions '[ + { + "action": "broadcast", + "label": "Take picture", + "extras": { + "cmd": "pic", + "camera": "front" + } + } + ]' \ + wifey \ + "Your wife requested you send a picture of yourself." + ``` + +=== "HTTP" + ``` http + POST / HTTP/1.1 + Host: ntfy.sh + + { + "topic": "wifey", + "message": "Your wife requested you send a picture of yourself.", + "actions": [ + { + "action": "broadcast", + "label": "Take picture", + "extras": { + "cmd": "pic", + "camera": "front" + } + } + ] + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh', { + method: 'POST', + body: JSON.stringify({ + topic: "wifey", + message": "Your wife requested you send a picture of yourself.", + actions: [ + { + "action": "broadcast", + "label": "Take picture", + "extras": { + "cmd": "pic", + "camera": "front" + } + } + ] + }) + }) + ``` + +=== "Go" + ``` go + // You should probably use json.Marshal() instead and make a proper struct, + // but for the sake of the example, this is easier. + + body := `{ + "topic": "wifey", + "message": "Your wife requested you send a picture of yourself.", + "actions": [ + { + "action": "broadcast", + "label": "Take picture", + "extras": { + "cmd": "pic", + "camera": "front" + } + } + ] + }` + req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras', + # otherwise it will read System.Collections.Hashtable in the returned JSON + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-Json -Depth 3 @{ + Topic = "wifey" + Message = "Your wife requested you send a picture of yourself." + Actions = @( + @{ + Action = "broadcast" + Label = "Take picture" + Extras = @{ + CMD ="pic" + Camera = "front" + } + } + ) + } + ContentType = "application/json" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/", + data=json.dumps({ + "topic": "wifey", + "message": "Your wife requested you send a picture of yourself.", + "actions": [ + { + "action": "broadcast", + "label": "Take picture", + "extras": { + "cmd": "pic", + "camera": "front" + } + } + ] + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => json_encode([ + "topic": "wifey", + "message": "Your wife requested you send a picture of yourself.", + "actions": [ + [ + "action": "broadcast", + "label": "Take picture", + "extras": [ + "cmd": "pic", + "camera": "front" + ] + ] + ]) + ] + ])); + ``` + +The `broadcast` action supports the following fields: + +| Field | Required | Type | Default | Example | Description | +|----------|----------|------------------|------------------------------|-------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | ✔️ | *string* | - | `broadcast` | Action type (**must be `broadcast`**) | +| `label` | ✔️ | *string* | - | `Turn on light` | Label of the action button in the notification | +| `intent` | -️ | *string* | `io.heckel.ntfy.USER_ACTION` | `com.example.AN_INTENT` | Android intent name, **default is `io.heckel.ntfy.USER_ACTION`** | +| `extras` | -️ | *map of strings* | - | *see above* | Android intent extras. Currently, only string extras are supported. When publishing as JSON, extras are passed as a map. When the simple format is used, use `extras.=`. | +| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped | + +### Send HTTP request +_Supported on:_ :material-android: :material-apple: :material-firefox: + +The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs +for whatever systems you have, e.g. opening the garage door, or turning on/off lights. + +By default, this action sends a **POST request** (not GET!), though this can be changed with the `method` parameter. +The only required parameter is `url`. Headers can be passed along using the `headers` parameter. + +Here's an example using the [`X-Actions` header](#using-a-header): + +=== "Command line (curl)" + ``` + curl \ + -d "Garage door has been open for 15 minutes. Close it?" \ + -H "Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ + ntfy.sh/myhome + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \ + myhome \ + "Garage door has been open for 15 minutes. Close it?" + ``` + +=== "HTTP" + ``` http + POST /myhome HTTP/1.1 + Host: ntfy.sh + Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"} + + Garage door has been open for 15 minutes. Close it? + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/myhome', { + method: 'POST', + body: 'Garage door has been open for 15 minutes. Close it?', + headers: { + 'Actions': 'http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?")) + req.Header.Set("Actions", "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/myhome" + Headers = @{ + Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" + } + Body = "Garage door has been open for 15 minutes. Close it?" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/myhome", + data="Garage door has been open for 15 minutes. Close it?", + headers={ "Actions": "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + 'Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}', + 'content' => 'Garage door has been open for 15 minutes. Close it?' + ] + ])); + ``` + +And the same example using [JSON publishing](#publish-as-json): + +=== "Command line (curl)" + ``` + curl ntfy.sh \ + -d '{ + "topic": "myhome", + "message": "Garage door has been open for 15 minutes. Close it?", + "actions": [ + { + "action": "http", + "label": "Close door", + "url": "https://api.mygarage.lan/", + "method": "PUT", + "headers": { + "Authorization": "Bearer zAzsx1sk.." + }, + "body": "{\"action\": \"close\"}" + } + ] + }' + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --actions '[ + { + "action": "http", + "label": "Close door", + "url": "https://api.mygarage.lan/", + "method": "PUT", + "headers": { + "Authorization": "Bearer zAzsx1sk.." + }, + "body": "{\"action\": \"close\"}" + } + ]' \ + myhome \ + "Garage door has been open for 15 minutes. Close it?" + ``` + +=== "HTTP" + ``` http + POST / HTTP/1.1 + Host: ntfy.sh + + { + "topic": "myhome", + "message": "Garage door has been open for 15 minutes. Close it?", + "actions": [ + { + "action": "http", + "label": "Close door", + "url": "https://api.mygarage.lan/", + "method": "PUT", + "headers": { + "Authorization": "Bearer zAzsx1sk.." + }, + "body": "{\"action\": \"close\"}" + } + ] + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh', { + method: 'POST', + body: JSON.stringify({ + topic: "myhome", + message": "Garage door has been open for 15 minutes. Close it?", + actions: [ + { + "action": "http", + "label": "Close door", + "url": "https://api.mygarage.lan/", + "method": "PUT", + "headers": { + "Authorization": "Bearer zAzsx1sk.." + }, + "body": "{\"action\": \"close\"}" + } + ] + }) + }) + ``` + +=== "Go" + ``` go + // You should probably use json.Marshal() instead and make a proper struct, + // but for the sake of the example, this is easier. + + body := `{ + "topic": "myhome", + "message": "Garage door has been open for 15 minutes. Close it?", + "actions": [ + { + "action": "http", + "label": "Close door", + "method": "PUT", + "url": "https://api.mygarage.lan/", + "headers": { + "Authorization": "Bearer zAzsx1sk.." + }, + "body": "{\"action\": \"close\"}" + } + ] + }` + req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Powershell requires the 'Depth' argument to equal 3 here to expand 'headers', + # otherwise it will read System.Collections.Hashtable in the returned JSON + + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-Json -Depth 3 @{ + Topic = "myhome" + Message = "Garage door has been open for 15 minutes. Close it?" + Actions = @( + @{ + Action = "http" + Label = "Close door" + URL = "https://api.mygarage.lan/" + Method = "PUT" + Headers = @{ + Authorization = "Bearer zAzsx1sk.." + } + Body = ConvertTo-JSON @{Action = "close"} + } + ) + } + ContentType = "application/json" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/", + data=json.dumps({ + "topic": "myhome", + "message": "Garage door has been open for 15 minutes. Close it?", + "actions": [ + { + "action": "http", + "label": "Close door", + "url": "https://api.mygarage.lan/", + "method": "PUT", + "headers": { + "Authorization": "Bearer zAzsx1sk.." + }, + "body": "{\"action\": \"close\"}" + } + ] + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => json_encode([ + "topic": "myhome", + "message": "Garage door has been open for 15 minutes. Close it?", + "actions": [ + [ + "action": "http", + "label": "Close door", + "url": "https://api.mygarage.lan/", + "method": "PUT", + "headers": [ + "Authorization": "Bearer zAzsx1sk.." + ], + "body": "{\"action\": \"close\"}" + ] + ] + ]) + ] + ])); + ``` + +The `http` action supports the following fields: + +| Field | Required | Type | Default | Example | Description | +|-----------|----------|--------------------|-----------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | ✔️ | *string* | - | `http` | Action type (**must be `http`**) | +| `label` | ✔️ | *string* | - | `Open garage door` | Label of the action button in the notification | +| `url` | ✔️ | *string* | - | `https://ntfy.sh/mytopic` | URL to which the HTTP request will be sent | +| `method` | -️ | *GET/POST/PUT/...* | `POST` ⚠️ | `GET` | HTTP method to use for request, **default is POST** ⚠️ | +| `headers` | -️ | *map of strings* | - | *see above* | HTTP headers to pass in request. When publishing as JSON, headers are passed as a map. When the simple format is used, use `headers.=`. | +| `body` | -️ | *string* | *empty* | `some body, somebody?` | HTTP body | +| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. | + +## Click action +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can define which URL to open when a notification is clicked. This may be useful if your notification is related +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. + +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. + +Examples: + +* `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 \ + -d "New messages on Reddit" \ + -H "Click: https://www.reddit.com/message/messages" \ + ntfy.sh/reddit_alerts + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --click="https://www.reddit.com/message/messages" \ + reddit_alerts "New messages on Reddit" + ``` + +=== "HTTP" + ``` http + POST /reddit_alerts HTTP/1.1 + Host: ntfy.sh + Click: https://www.reddit.com/message/messages + + New messages on Reddit + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/reddit_alerts', { + method: 'POST', + body: 'New messages on Reddit', + headers: { 'Click': 'https://www.reddit.com/message/messages' } + }) + ``` + +=== "Go" + ``` go + 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) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + 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/reddit_alerts", + data="New messages on Reddit", + headers={ "Click": "https://www.reddit.com/message/messages" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Click: https://www.reddit.com/message/messages", + 'content' => 'New messages on Reddit' + ] + ])); + ``` + +## Attachments +_Supported on:_ :material-android: :material-firefox: + +You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded +onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. + +There are two different ways to send attachments: + +* 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` + +### 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`). + +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). + +Here's an example showing how to upload an image: + +=== "Command line (curl)" + ``` + curl \ + -T flower.jpg \ + -H "Filename: flower.jpg" \ + ntfy.sh/flowers + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --file=flower.jpg \ + flowers + ``` + +=== "HTTP" + ``` http + PUT /flowers HTTP/1.1 + Host: ntfy.sh + Filename: flower.jpg + Content-Type: 52312 + + (binary JPEG data) + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/flowers', { + method: 'PUT', + body: document.getElementById("file").files[0], + headers: { 'Filename': 'flower.jpg' } + }) + ``` + +=== "Go" + ``` go + file, _ := os.Open("flower.jpg") + req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file) + req.Header.Set("Filename", "flower.jpg") + http.DefaultClient.Do(req) + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/flowers", + data=open("flower.jpg", 'rb'), + headers={ "Filename": "flower.jpg" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([ + 'http' => [ + '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 + ] + ])); + ``` + +Here's what that looks like on Android: + +
+ ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 } +
Image attachment sent from a local file
+
+ +### 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. + +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. + +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 showing how to attach an APK file: + +=== "Command line (curl)" + ``` + curl \ + -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 /mydownloads HTTP/1.1 + Host: ntfy.sh + Attach: https://f-droid.org/F-Droid.apk + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mydownloads', { + method: 'POST', + headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file) + req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mydownloads" + Headers = @{ Attach="https://f-droid.org/F-Droid.apk" } + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/mydownloads", + headers={ "Attach": "https://f-droid.org/F-Droid.apk" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Attach: https://f-droid.org/F-Droid.apk", + ] + ])); + ``` + +
+ ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 } +
File attachment sent from an external URL
+
+ +## Icons +_Supported on:_ :material-android: + +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**. + +Here's an example showing how to include an icon: + +=== "Command line (curl)" + ``` + 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 \ + --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + --title="Kodi: Resuming Playback" \ + --tags="arrow_forward" \ + tvshows \ + "The Wire, S01E01" + ``` + +=== "HTTP" + ``` http + 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/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 + 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 + $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.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/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" + ])); + ``` + +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
+
+ +## E-mail notifications +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that +you'd like to persist longer, or to blast-notify yourself on all possible channels. + +Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). +Only one e-mail address is supported. + +Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the +default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of +that, your IP address appears in the e-mail body. This is to prevent abuse. + +=== "Command line (curl)" + ``` + curl \ + -H "Email: phil@example.com" \ + -H "Tags: warning,skull,backup-host,ssh-login" \ + -H "Priority: high" \ + -d "Unknown login from 5.31.23.83 to backups.example.com" \ + ntfy.sh/alerts + curl -H "Email: phil@example.com" -d "You've Got Mail" + curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --email=phil@example.com \ + --tags=warning,skull,backup-host,ssh-login \ + --priority=high \ + alerts "Unknown login from 5.31.23.83 to backups.example.com" + ``` + +=== "HTTP" + ``` http + POST /alerts HTTP/1.1 + Host: ntfy.sh + Email: phil@example.com + Tags: warning,skull,backup-host,ssh-login + Priority: high + + Unknown login from 5.31.23.83 to backups.example.com + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/alerts', { + method: 'POST', + body: "Unknown login from 5.31.23.83 to backups.example.com", + headers: { + 'Email': 'phil@example.com', + 'Tags': 'warning,skull,backup-host,ssh-login', + 'Priority': 'high' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", + strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com")) + req.Header.Set("Email", "phil@example.com") + req.Header.Set("Tags", "warning,skull,backup-host,ssh-login") + req.Header.Set("Priority", "high") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/alerts" + Headers = @{ + Title = "Low disk space alert" + Priority = "high" + Tags = "warning,skull,backup-host,ssh-login") + Email = "phil@example.com" + } + Body = "Unknown login from 5.31.23.83 to backups.example.com" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/alerts", + data="Unknown login from 5.31.23.83 to backups.example.com", + headers={ + "Email": "phil@example.com", + "Tags": "warning,skull,backup-host,ssh-login", + "Priority": "high" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Email: phil@example.com\r\n" . + "Tags: warning,skull,backup-host,ssh-login\r\n" . + "Priority: high", + 'content' => 'Unknown login from 5.31.23.83 to backups.example.com' + ] + ])); + ``` + +Here's what that looks like in Google Mail: + +
+ ![e-mail notification](static/img/screenshot-email.png){ width=600 } +
E-mail notification
+
+ +## E-mail publishing +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can +publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail +based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls). + +Depending on the [server configuration](config.md#e-mail-publishing), the e-mail address format can have a prefix to +prevent spam on topics. For ntfy.sh, the prefix is configured to `ntfy-`, meaning that the general e-mail address +format is: + +``` +ntfy-$topic@ntfy.sh +``` + +If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work without providing an authorized access token. That will change the format of the e-mail's recipient address to +``` +ntfy-$topic+$token@ntfy.sh +``` + +As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority, +delay and other features are not supported (yet). Here's an example that will publish a message with the +title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)): + +
+ ![e-mail publishing](static/img/screenshot-email-publishing-gmail.png){ width=500 } +
Publishing a message via e-mail
+
+ +## Phone calls +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can use ntfy to call a phone and **read the message out loud using text-to-speech**. +Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have +the ntfy app installed on their phone. + +**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is +**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone +number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. +You may also simply pass `yes` as a value to pick the first of your verified phone numbers. +On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. + +
+ ![phone number verification](static/img/web-phone-verify.png) +
Phone number verification in the web app
+
+ +As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll +be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). + +!!! info + You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**. + This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or + violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated. + +Here's how you use it: + +=== "Command line (curl)" + ``` + curl \ + -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + -H "Call: +12223334444" \ + -d "Your garage seems to be on fire. You should probably check that out." \ + ntfy.sh/alerts + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + --call=+12223334444 \ + alerts "Your garage seems to be on fire. You should probably check that out." + ``` + +=== "HTTP" + ``` http + POST /alerts HTTP/1.1 + Host: ntfy.sh + Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 + Call: +12223334444 + + Your garage seems to be on fire. You should probably check that out. + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/alerts', { + method: 'POST', + body: "Your garage seems to be on fire. You should probably check that out.", + headers: { + 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', + 'Call': '+12223334444' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", + strings.NewReader("Your garage seems to be on fire. You should probably check that out.")) + req.Header.Set("Call", "+12223334444") + req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/alerts" + Headers = @{ + Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + Call = "+12223334444" + } + Body = "Your garage seems to be on fire. You should probably check that out." + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/alerts", + data="Your garage seems to be on fire. You should probably check that out.", + headers={ + "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", + "Call": "+12223334444" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" . + "Call: +12223334444", + 'content' => 'Your garage seems to be on fire. You should probably check that out.' + ] + ])); + ``` + +Here's what a phone call from ntfy sounds like: + + + +Audio transcript: + +> You have a notification from ntfy on topic alerts. +> Message: Your garage seems to be on fire. You should probably check that out. End message. +> This message was sent by user phil. It will be repeated up to three times. + +## Authentication +Depending on whether the server is configured to support [access control](config.md#access-control), some topics +may be read/write protected so that only users with the correct credentials can subscribe or publish to them. +To publish/subscribe to protected topics, you can: + +* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk` +* Use [access tokens](#bearer-auth) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2` +* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw` + +!!! warning + When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your + self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password. + +### Username + password +The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication). +Here's an example with a user `testuser` and password `fakepassword`: + +=== "Command line (curl)" + ``` + curl \ + -u testuser:fakepassword \ + -d "Look ma, with auth" \ + https://ntfy.example.com/mysecrets + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -u testuser:fakepassword \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets HTTP/1.1 + Host: ntfy.example.com + Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets', { + method: 'POST', // PUT works too + body: 'Look ma, with auth', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", + strings.NewReader("Look ma, with auth")) + req.Header.Set("Authorization", "Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell 7+" + ``` powershell + # Get the credentials from the user + $Credential = Get-Credential testuser + + # Alternatively, create a PSCredential object with the password from scratch + $Credential = [PSCredential]::new("testuser", (ConvertTo-SecureString "password" -AsPlainText -Force)) + + # Note that the Authentication parameter requires PowerShell 7 or later + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Authentication = "Basic" + Credential = $Credential + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "PowerShell 5 and earlier" + ``` powershell + # With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves + $CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)" + $EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString)) + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Headers = @{ Authorization = "Basic $EncodedCredential"} + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets", + data="Look ma, with auth", + headers={ + "Authorization": "Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + 'Content-Type: text/plain\r\n' . + 'Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + +To generate the `Authorization` header, use **standard base64** to encode the colon-separated `:` +and prepend the word `Basic`, i.e. `Authorization: Basic base64(:)`. Here's some pseudo-code that +hopefully explains it better: + +``` +username = "testuser" +password = "fakepassword" +authHeader = "Basic " + base64(username + ":" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk +``` + +The following command will generate the appropriate value for you on *nix systems: + +``` +echo "Basic $(echo -n 'testuser:fakepassword' | base64)" +``` + +### Access tokens +In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful +to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may +want to use a dedicated token to publish from your backup host, and one from your home automation system. + +You can create access tokens using the `ntfy token` command, or in the web app in the "Account" section (when logged in). +See [access tokens](config.md#access-tokens) for details. + +Once an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or +subscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/), +with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`: + +=== "Command line (curl)" + ``` + curl \ + -H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \ + -d "Look ma, with auth" \ + https://ntfy.example.com/mysecrets + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets HTTP/1.1 + Host: ntfy.example.com + Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets', { + method: 'POST', // PUT works too + body: 'Look ma, with auth', + headers: { + 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", + strings.NewReader("Look ma, with auth")) + req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell 7+" + ``` powershell + # With PowerShell 7 or greater, we can use the Authentication and Token parameters + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Authorization = "Bearer" + Token = "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "PowerShell 5 and earlier" + ``` powershell + # In PowerShell 5 and below, we can only send the Bearer token as a string in the Headers + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Headers = @{ Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" } + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets", + data="Look ma, with auth", + headers={ + "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + 'Content-Type: text/plain\r\n' . + 'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + +Alternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the +access token. When sending an empty username, the basic auth password is treated by the ntfy server as an +access token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`: + +=== "Command line (curl)" + ``` + curl \ + -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + -d "Look ma, with auth" \ + https://ntfy.example.com/mysecrets + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets HTTP/1.1 + Host: ntfy.example.com + Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets', { + method: 'POST', // PUT works too + body: 'Look ma, with auth', + headers: { + 'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets", + strings.NewReader("Look ma, with auth")) + req.Header.Set("Authorization", "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Note that PSCredentials *must* have a username, so we fall back to placing the authorization in the Headers as with PowerShell 5 + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets" + Headers = @{ + Authorization = "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy" + } + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets", + data="Look ma, with auth", + headers={ + "Authorization": "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => + 'Content-Type: text/plain\r\n' . + 'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + + +### Query param +Here's an example using the `auth` query parameter: + +=== "Command line (curl)" + ``` + curl \ + -d "Look ma, with auth" \ + "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -u testuser:fakepassword \ + ntfy.example.com/mysecrets \ + "Look ma, with auth" + ``` + +=== "HTTP" + ``` http + POST /mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw HTTP/1.1 + Host: ntfy.example.com + + Look ma, with auth + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', { + method: 'POST', // PUT works too + body: 'Look ma, with auth' + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw", + strings.NewReader("Look ma, with auth")) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw" + Body = "Look ma, with auth" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw", + data="Look ma, with auth" + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', // PUT also works + 'header' => 'Content-Type: text/plain', + 'content' => 'Look ma, with auth' + ] + ])); + ``` + +To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using +**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully +explains it better: + +``` +username = "testuser" +password = "fakepassword" +authHeader = "Basic " + base64(username + ":" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk +authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw (no trailing =) + +// If your language does not have a function to encode raw base64, simply use normal base64 +// and REMOVE TRAILING "=" characters. +``` + +The following command will generate the appropriate value for you on *nix systems: + +``` +echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '=' +``` + +For access tokens, you can use this instead: + +``` +echo -n "Bearer faketoken" | base64 | tr -d '=' +``` + +## Advanced features + +### Message caching +!!! info + If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a + client re-connects. If a subscriber has (temporary) network issues or is reconnecting momentarily, + **messages might be missed**. + +By default, the ntfy server caches messages on disk for 12 hours (see [message caching](config.md#message-cache)), so +all messages you publish are stored server-side for a little while. The reason for this is to overcome temporary +client-side network disruptions, but arguably this feature also may raise privacy concerns. + +To avoid messages being cached server-side entirely, you can set `X-Cache` header (or its alias: `Cache`) to `no`. +This will make sure that your message is not cached on the server, even if server-side caching is enabled. Messages +are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fetch-cached-messages) and +[`poll=1`](subscribe/api.md#poll-for-messages) won't return the message anymore. + +=== "Command line (curl)" + ``` + curl -H "X-Cache: no" -d "This message won't be stored server-side" ntfy.sh/mytopic + curl -H "Cache: no" -d "This message won't be stored server-side" ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --no-cache \ + mytopic "This message won't be stored server-side" + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + Cache: no + + This message won't be stored server-side + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', + body: 'This message won't be stored server-side', + headers: { 'Cache': 'no' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", strings.NewReader("This message won't be stored server-side")) + req.Header.Set("Cache", "no") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Headers = @{ Cache="no" } + Body = "This message won't be stored server-side" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mytopic", + data="This message won't be stored server-side", + headers={ "Cache": "no" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Cache: no", + 'content' => 'This message won't be stored server-side' + ] + ])); + ``` + +### Disable Firebase +!!! info + If `Firebase: no` is used and [instant delivery](subscribe/phone.md#instant-delivery) isn't enabled in the Android + app (Google Play variant only), **message delivery will be significantly delayed (up to 15 minutes)**. To overcome + this delay, simply enable instant delivery. + +The ntfy server can be configured to use [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) +(see [Firebase config](config.md#firebase-fcm)) for message delivery on Android (to minimize the app's battery footprint). +The ntfy.sh server is configured this way, meaning that all messages published to ntfy.sh are also published to corresponding +FCM topics. + +If you'd like to avoid forwarding messages to Firebase, you can set the `X-Firebase` header (or its alias: `Firebase`) +to `no`. This will instruct the server not to forward messages to Firebase. + +=== "Command line (curl)" + ``` + curl -H "X-Firebase: no" -d "This message won't be forwarded to FCM" ntfy.sh/mytopic + curl -H "Firebase: no" -d "This message won't be forwarded to FCM" ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --no-firebase \ + mytopic "This message won't be forwarded to FCM" + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + Firebase: no + + This message won't be forwarded to FCM + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', + body: 'This message won't be forwarded to FCM', + headers: { 'Firebase': 'no' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", strings.NewReader("This message won't be forwarded to FCM")) + req.Header.Set("Firebase", "no") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic" + Headers = @{ Firebase="no" } + Body = "This message won't be forwarded to FCM" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/mytopic", + data="This message won't be forwarded to FCM", + headers={ "Firebase": "no" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Firebase: no", + 'content' => 'This message won't be stored server-side' + ] + ])); + ``` + +### UnifiedPush +!!! info + This setting is not relevant to users, only to app developers and people interested in [UnifiedPush](https://unifiedpush.org). + +[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned +[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications +in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it. + +When publishing messages to a topic, apps using ntfy as a UnifiedPush distributor can set the `X-UnifiedPush` header or query +parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Firebase](#disable-firebase). As of today, this +option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally +enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64. + +### Matrix Gateway +The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with +[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate +with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since +you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)). + +In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)), +and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the +ntfy Android app, and passed on to the Matrix client there. + +There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the +ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol. + +!!! info + This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy. + +## Public topics +Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics +that you can use to try out what [authentication and access control](#authentication) looks like. + +| Topic | User | Permissions | Description | +|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------| +| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such | +| [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage | + +## Limitations +There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings +are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, +but just in case, let's list them all: + +| Limit | Description | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | +| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | +| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | +| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | +| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | +| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | + +These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing +a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. + +## List of all parameters +The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive** +when used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the +table in their canonical form. + +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any + header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + +| Parameter | Aliases | Description | +|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------| +| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification | +| `X-Title` | `Title`, `t` | [Message title](#message-title) | +| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) | +| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) | +| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) | +| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) | +| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) | +| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment | +| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body | +| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | +| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | +| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | +| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) | +| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | +| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | +| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | +| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) | +| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics | +| `Content-Type` | - | If set to `text/markdown`, [Markdown formatting](#markdown-formatting) is enabled | diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 00000000..73e5eb20 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,1310 @@ +# Release notes +Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) +and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). + +## ntfy server v2.7.0 +Released August 17, 2023 + +This release ships Markdown support for the web app (not in the Android app yet), and adds support for +right-to-left languages (RTL) in the web app. It also fixes a few issues around date/time formatting, +internationalization support, a CLI auth bug. + +Furthermore, it fixes a security issue around access tokens getting erroneously deleted for other users +in a specific scenario. This was a denial-of-service-type security issue, since it **effectively allowed a +single user to deny access to all other users of a ntfy instance**. Please note that while tokens were +erroneously deleted, **nobody but the token owner ever had access to it.** Please refer to [the ticket](https://github.com/binwiederhier/ntfy/issues/838) +for details. **Please upgrade your ntfy instance if you run a multi-user system.** + +**Features:** + +* Add support for [Markdown formatting](publish.md#markdown-formatting) in web app ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves)) +* Add support for right-to-left languages (RTL) in the web app ([#663](https://github.com/binwiederhier/ntfy/issues/663), thanks to [@nimbleghost](https://github.com/nimbleghost)) + +**Security:** ⚠️ + +* Fixes issue with access tokens getting deleted ([#838](https://github.com/binwiederhier/ntfy/issues/838)) + +**Bug fixes + maintenance:** + +* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Re-init i18n on each service worker message to avoid missing translations ([#817](https://github.com/binwiederhier/ntfy/pull/817), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves)) +* You can now unset the default user:pass/token in `client.yml` for an individual subscription to remove the Authorization header ([#829](https://github.com/binwiederhier/ntfy/issues/829), thanks to [@tomeon](https://github.com/tomeon) for reporting and to [@wunter8](https://github.com/wunter8) for fixing) + +**Documentation:** + +* Update docs for Apache config ([#819](https://github.com/binwiederhier/ntfy/pull/819), thanks to [@nisbet-hubbard](https://github.com/nisbet-hubbard)) + +## ntfy server v2.6.2 +Released June 30, 2023 + +With this release, the ntfy web app now contains a **[progressive web app](subscribe/pwa.md) (PWA) +with Web Push support**, which means you'll be able to **install the ntfy web app on your desktop or phone** similar +to a native app (__even on iOS!__ 🥳). Installing the PWA gives ntfy web its own launcher, a standalone window, +push notifications, and an app badge with the unread notification count. Note that for self-hosted servers, +[Web Push](config.md#web-push) must be configured. + +On top of that, this release also brings **dark mode** 🧛🌙 to the web app. + +🙏 A huge thanks for this release goes to [@nimbleghost](https://github.com/nimbleghost), for basically implementing the +Web Push / PWA and dark mode feature by himself. I'm really grateful for your contributions. + +❤️ If you like ntfy, **please consider sponsoring us** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) +and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app) (20% off +if you use promo code `MYTOPIC`). ntfy will always remain open source. + +**Features:** + +* The web app now supports Web Push, and is installable as a [progressive web app (PWA)](https://docs.ntfy.sh/subscribe/pwa/) on Chrome, Edge, Android, and iOS ([#751](https://github.com/binwiederhier/ntfy/pull/751), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Support for dark mode in the web app ([#206](https://github.com/binwiederhier/ntfy/issues/206), thanks to [@nimbleghost](https://github.com/nimbleghost)) + +**Bug fixes:** + +* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) +* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting) +* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting) +* Newly created access tokens are now lowercase only to fully support `+@` email syntax ([#773](https://github.com/binwiederhier/ntfy/issues/773), thanks to gingervitiz for reporting) +* The .1 release fixes a few visual issues with dark mode, and other web app updates ([#791](https://github.com/binwiederhier/ntfy/pull/791), [#793](https://github.com/binwiederhier/ntfy/pull/793), [#792](https://github.com/binwiederhier/ntfy/pull/792), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* The .2 release fixes issues with the service worker in Firefox and adds automatic service worker updates ([#795](https://github.com/binwiederhier/ntfy/pull/795), thanks to [@nimbleghost](https://github.com/nimbleghost)) + +**Maintenance:** + +* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost)) + +**Changes in tarball/zip naming:** +Due to a [change in GoReleaser](https://goreleaser.com/deprecations/#archivesreplacements), some of the binary release +archives now have slightly different names. My apologies if this causes issues in the downstream projects that use ntfy: + +- `ntfy_v${VERSION}_windows_x86_64.zip` -> `ntfy_v${VERSION}_windows_amd64.zip` +- `ntfy_v${VERSION}_linux_x86_64.tar.gz` -> `ntfy_v${VERSION}_linux_amd64.tar.gz` +- `ntfy_v${VERSION}_macOS_all.tar.gz` -> `ntfy_v${VERSION}_darwin_all.tar.gz` + +## ntfy server v2.5.0 +Released May 18, 2023 + +This release brings a number of new features, including support for text-to-speech style [phone calls](publish.md#phone-calls), +an admin API to manage users and ACL (currently in beta, and hence undocumented), and support for authorized access to +upstream servers via the `upstream-access-token` config option. + +❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) +and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app) (20% off +if you use promo code `MYTOPIC`). ntfy will always remain open source. + +**Features:** + +* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket) +* Admin API to manage users and ACL, `v1/users` + `v1/users/access` (intentionally undocumented as of now, [#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket) +* Added `upstream-access-token` config option to allow authorized access to upstream servers (no ticket) + +**Bug fixes + maintenance:** + +* Removed old ntfy website from ntfy entirely (no ticket) +* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike)) +* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing) +* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion)) +* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) +* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting) + +## ntfy server v2.4.0 +Released Apr 26, 2023 + +This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds support to encode the `X-Title`, +`X-Message` and `X-Tags` header as RFC 2047. It's a pretty small release, and mainly enables the release of the new ntfy.sh website. + +❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) +and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy +will always remain open source. + +**Features:** + +* [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) can now be installed via Homebrew (thanks to [@Moulick](https://github.com/Moulick)) +* Added `v1/stats` endpoint to expose messages stats (no ticket) +* Support [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2) encoded headers (no ticket, honorable mention to [mqttwarn](https://github.com/jpmens/mqttwarn/pull/638) and [@amotl](https://github.com/amotl)) + +**Bug fixes + maintenance:** + +* Hide country flags on Windows ([#606](https://github.com/binwiederhier/ntfy/issues/606), thanks to [@cmeis](https://github.com/cmeis) for reporting, and to [@pokej6](https://github.com/pokej6) for fixing it) +* `ntfy sub` now uses default auth credentials as defined in `client.yml` ([#698](https://github.com/binwiederhier/ntfy/issues/698), thanks to [@CrimsonFez](https://github.com/CrimsonFez) for reporting, and to [@wunter8](https://github.com/wunter8) for fixing it) + +**Documentation:** + +* Updated PowerShell examples ([#697](https://github.com/binwiederhier/ntfy/pull/697), thanks to [@Natfan](https://github.com/Natfan)) + +**Additional languages:** + +* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/)) + +## ntfy server v2.3.1 +Released March 30, 2023 + +This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem +on ntfy.sh that we observe every 20 minutes. The polling was never strictly necessary, and has actually caused duplicate +delivery issues as well, so disabling it should not have any negative effects. iOS users, please reach out via Discord +or Matrix if there are issues. + +**Bug fixes + maintenance:** + +* Disable iOS polling entirely ([#677](https://github.com/binwiederhier/ntfy/issues/677)/[#509](https://github.com/binwiederhier/ntfy/issues/509)) + +## ntfy server v2.3.0 +Released March 29, 2023 + +This release primarily fixes an issue with delayed messages, and it adds support for Go's profiler (if enabled), which +will allow investigating usage spikes in more detail. There will likely be a follow-up release this week to fix the +actual spikes [caused by iOS devices](https://github.com/binwiederhier/ntfy/issues/677). + +**Features:** + +* ntfy now supports Go's `pprof` profiler, if enabled (relates to [#677](https://github.com/binwiederhier/ntfy/issues/677)) + +**Bug fixes + maintenance:** + +* Fix delayed message sending from authenticated users ([#679](https://github.com/binwiederhier/ntfy/issues/679)) +* Fixed plural for Polish and other translations ([#678](https://github.com/binwiederhier/ntfy/pull/678), thanks to [@bmoczulski](https://github.com/bmoczulski)) + +## ntfy server v2.2.0 +Released March 17, 2023 + +With this release, ntfy is now able to expose metrics via a `/metrics` endpoint for [Prometheus](https://prometheus.io/), if enabled. +The endpoint exposes about 20 different counters and gauges, from the number of published messages and emails, to active subscribers, +visitors and topics. If you'd like more metrics, pop in the Discord/Matrix or file an issue on GitHub. + +On top of this, you can now use access tokens in the ntfy CLI (defined in the `client.yml` file), fixed a bug in `ntfy subscribe`, +removed the dependency on Google Fonts, and more. + +🔥 Reminder: Purchase one of three **ntfy Pro plans** for **50% off** for a limited time (if you use promo code `MYTOPIC`). +ntfy Pro gives you higher rate limits and lets you reserve topic names. [Buy through web app](https://ntfy.sh/app). + +❤️ If you don't need ntfy Pro, please consider sponsoring ntfy via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) +and [Liberapay](https://en.liberapay.com/ntfy/). ntfy will stay open source forever. + +**Features:** + +* Monitoring: ntfy now exposes a `/metrics` endpoint for [Prometheus](https://prometheus.io/) if [configured](config.md#monitoring) ([#210](https://github.com/binwiederhier/ntfy/issues/210), thanks to [@rogeliodh](https://github.com/rogeliodh) for reporting) +* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8)) + +**Bug fixes + maintenance:** + +* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8)) +* Docs: Removed dependency on Google Fonts in docs ([#554](https://github.com/binwiederhier/ntfy/issues/554), thanks to [@bt90](https://github.com/bt90) for reporting, and [@ozskywalker](https://github.com/ozskywalker) for implementing) +* Increase allowed auth failure attempts per IP address to 30 (no ticket) +* Web app: Increase maximum incremental backoff retry interval to 2 minutes (no ticket) + +**Documentation:** + +* Make query parameter description more clear ([#630](https://github.com/binwiederhier/ntfy/issues/630), thanks to [@bbaa-bbaa](https://github.com/bbaa-bbaa) for reporting, and to [@wunter8](https://github.com/wunter8) for a fix) + +## ntfy server v2.1.2 +Released March 4, 2023 + +This is a hotfix release, mostly to combat the ridiculous amount of Matrix requests with invalid/dead pushkeys, and the +corresponding HTTP 507 responses the ntfy.sh server is sending out. We're up to >600k HTTP 507 responses per day 🤦. This +release solves this issue by rejecting Matrix pushkeys, if nobody has subscribed to the corresponding topic for 12 hours. + +The release furthermore reverts the default rate limiting behavior for UnifiedPush to be publisher-based, and introduces +a flag to enable [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) for high volume servers. + +**Features:** + +* Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting) + +**Bug fixes + maintenance:** + +* Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder)) +* Add `visitor-subscriber-rate-limiting` flag to allow enabling [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) (off by default now, [#649](https://github.com/binwiederhier/ntfy/issues/649)/[#655](https://github.com/binwiederhier/ntfy/pull/655), thanks to [@barathrm](https://github.com/barathrm) for reporting, and to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design) +* Reject Matrix pushkey after 12 hours of inactivity on a topic, if `visitor-subscriber-rate-limiting` is enabled ([#643](https://github.com/binwiederhier/ntfy/pull/643), thanks to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design) + +**Additional languages:** + +* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/)) + +## ntfy server v2.1.1 +Released March 1, 2023 + +This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work, +**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉. + +You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...), +as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the +ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use +promo code `MYTOPIC` for a **50% discount**, limited time only). + +And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There +are no closed-source features. So if you'd like to run your own server, you can! + +**Bug fixes + maintenance:** + +* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting) +* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha)) +* Upgrade dialog: Disable submit button for free tier (no ticket) +* Allow multiple `log-level-overrides` on the same field (no ticket) +* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket) +* Added `billing-contact` config option (no ticket) + +## ntfy server v2.1.0 +Released February 25, 2023 + +This release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based +rate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits. +However, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do +no, clients will receive an HTTP 507 response from the server. + +We also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers, +which ntfy rejected with an HTTP 401. We now ignore unsupported header values. + +As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing +cycles (not live yet). + +As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate +limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits +a bit more. For 90% of users, you should not feel the difference. + +**Features:** + +* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm)) +* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore)) +* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts)) +* Payments: Add support for annual billing intervals (no ticket) + +**Bug fixes + maintenance:** + +* Web: Do not disable "Reserve topic" checkbox for admins (no ticket, thanks to @xenrox for reporting) +* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting) + +**Documentation:** + +* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore)) + +**Additional languages:** + +* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/)) + +## ntfy server v2.0.1 +Released February 17, 2023 + +This is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set. + +**Bug fixes + maintenance:** + +* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl)) +* Ensure that calls to standard logger `log.Println` also output JSON (no ticket) + +## ntfy server v2.0.0 +Released February 16, 2023 + +This is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features. + +**Brand-new features:** + +* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to + the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as + opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS + app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and + `enable-login`. +
+ + +
+* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**. + Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe + to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as + `deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you + can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner). + Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and + only if the user has a [tier](config.md#tiers) that supports reservations. +
+ + +
+* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful + to avoid having to paste your password to various applications or scripts. For instance, you may want to use a + dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured + in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens), + and [publishing using tokens](publish.md#access-tokens). +
+ + +
+* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and + troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields + that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields + match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level. + See [logging & debugging](config.md#logging-debugging). +* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as + daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have + a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another + tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below). + Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers). +* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be + able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations. + Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments) + for details. + +**ntfy is forever open source!** +Yes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything +will remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) +and [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep +going. It'll only make ntfy better. + +**Other tickets:** + +* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522)) +* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting) + +**Special thanks:** + +A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback, +suggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot. + +## ntfy server v1.31.0 +Released February 14, 2023 + +This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting +things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal +of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a +breaking-change upgrade, which required some work to get working again. + +**Features:** + +* Preliminary `/v1/health` API endpoint for service monitoring (no ticket) +* Add basic health check to `Dockerfile` ([#555](https://github.com/binwiederhier/ntfy/pull/555), thanks to [@bt90](https://github.com/bt90)) + +**Bug fixes + maintenance:** + +* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus)) +* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting) +* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569)) + +**Documentation:** + +* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90)) +* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD)) +* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan)) +* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n)) +* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev)) + +**Additional languages:** + +* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/)) + +## ntfy server v1.30.1 +Released December 23, 2022 🎅 + +This is a special holiday edition version of ntfy, with all sorts of holiday fun and games, and hidden quests. +Nahh, just kidding. This release is an intermediate release mainly to eliminate warnings in the logs, so I can +roll out the TLSv1.3, HTTP/2 and Unix mode changes on ntfy.sh (see [#552](https://github.com/binwiederhier/ntfy/issues/552)). + +**Features:** + +* Web: Generate random topic name button ([#453](https://github.com/binwiederhier/ntfy/issues/453), thanks to [@yardenshoham](https://github.com/yardenshoham)) +* Add [Gitpod config](https://github.com/binwiederhier/ntfy/blob/main/.gitpod.yml) ([#540](https://github.com/binwiederhier/ntfy/pull/540), thanks to [@yardenshoham](https://github.com/yardenshoham)) + +**Bug fixes + maintenance:** + +* Remove `--env-topic` option from `ntfy publish` as per [deprecation](deprecations.md) (no ticket) +* Prepared statements for message cache writes ([#542](https://github.com/binwiederhier/ntfy/pull/542), thanks to [@nicois](https://github.com/nicois)) +* Do not warn about invalid IP address when behind proxy in unix socket mode (relates to [#552](https://github.com/binwiederhier/ntfy/issues/552)) +* Upgrade nginx/ntfy config on ntfy.sh to work with TLSv1.3, HTTP/2 ([#552](https://github.com/binwiederhier/ntfy/issues/552), thanks to [@bt90](https://github.com/bt90)) + +## ntfy Android app v1.16.0 +Released December 11, 2022 + +This is a feature and platform/dependency upgrade release. You can now have per-subscription notification settings +(including sounds, DND, etc.), and you can make notifications continue ringing until they are dismissed. There's also +support for thematic/adaptive launcher icon for Android 13. + +There are a few more Android 13 specific things, as well as many bug fixes: No more crashes from large images, no more +opening the wrong subscription, and we also fixed the icon color issue. + +**Features:** + +* Custom per-subscription notification settings incl. sounds, DND, etc. ([#6](https://github.com/binwiederhier/ntfy/issues/6), thanks to [@doits](https://github.com/doits)) +* Insistent notifications that ring until dismissed ([#417](https://github.com/binwiederhier/ntfy/issues/417), thanks to [@danmed](https://github.com/danmed) for reporting) +* Add thematic/adaptive launcher icon ([#513](https://github.com/binwiederhier/ntfy/issues/513), thanks to [@daedric7](https://github.com/daedric7) for reporting) + +**Bug fixes + maintenance:** + +* Upgrade Android dependencies and build toolchain to SDK 33 (no ticket) +* Simplify F-Droid build: Disable tasks for Google Services ([#516](https://github.com/binwiederhier/ntfy/issues/516), thanks to [@markosopcic](https://github.com/markosopcic)) +* Android 13: Ask for permission to post notifications ([#508](https://github.com/binwiederhier/ntfy/issues/508)) +* Android 13: Do not allow swiping away the foreground notification ([#521](https://github.com/binwiederhier/ntfy/issues/521), thanks to [@alexhorner](https://github.com/alexhorner) for reporting) +* Android 5 (SDK 21): Fix crash on unsubscribing ([#528](https://github.com/binwiederhier/ntfy/issues/528), thanks to Roger M.) +* Remove timestamp when copying message text ([#471](https://github.com/binwiederhier/ntfy/issues/471), thanks to [@wunter8](https://github.com/wunter8)) +* Fix auto-delete if some icons do not exist anymore ([#506](https://github.com/binwiederhier/ntfy/issues/506)) +* Fix notification icon color ([#480](https://github.com/binwiederhier/ntfy/issues/480), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting) +* Fix topics do not re-subscribe to Firebase after restoring from backup ([#511](https://github.com/binwiederhier/ntfy/issues/511)) +* Fix crashes from large images ([#474](https://github.com/binwiederhier/ntfy/issues/474), thanks to [@daedric7](https://github.com/daedric7) for reporting) +* Fix notification click opens wrong subscription ([#261](https://github.com/binwiederhier/ntfy/issues/261), thanks to [@SMAW](https://github.com/SMAW) for reporting) +* Fix Firebase-only "link expired" issue ([#529](https://github.com/binwiederhier/ntfy/issues/529)) +* Remove "Install .apk" feature in Google Play variant due to policy change ([#531](https://github.com/binwiederhier/ntfy/issues/531)) +* Add donate button (no ticket) + +**Additional translations:** + +* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/)) +* Portuguese (thanks to [@victormagalhaess](https://hosted.weblate.org/user/victormagalhaess/)) + +## ntfy server v1.29.1 +Released November 17, 2022 + +This is mostly a bugfix release to address the high load on ntfy.sh. There are now two new options that allow +synchronous batch-writing of messages to the cache. This avoids database locking, and subsequent pileups of waiting +requests. + +**Bug fixes:** + +* High-load servers: Allow asynchronous batch-writing of messages to cache via `cache-batch-*` options ([#498](https://github.com/binwiederhier/ntfy/issues/498)/[#502](https://github.com/binwiederhier/ntfy/pull/502)) +* Sender column in cache.db shows invalid IP ([#503](https://github.com/binwiederhier/ntfy/issues/503)) + +**Documentation:** + +* GitHub Actions example ([#492](https://github.com/binwiederhier/ntfy/pull/492), thanks to [@ksurl](https://github.com/ksurl)) +* UnifiedPush ACL clarification ([#497](https://github.com/binwiederhier/ntfy/issues/497), thanks to [@bt90](https://github.com/bt90)) +* Install instructions for Kustomize ([#463](https://github.com/binwiederhier/ntfy/pull/463), thanks to [@l-maciej](https://github.com/l-maciej)) + +**Other things:** + +* Put ntfy.sh docs on GitHub pages to reduce AWS outbound traffic cost ([#491](https://github.com/binwiederhier/ntfy/issues/491)) +* The ntfy.sh server hardware was upgraded to a bigger box. If you'd like to help out carrying the server cost, **[sponsorships and donations](https://github.com/sponsors/binwiederhier)** 💸 would be very much appreciated + +## ntfy server v1.29.0 +Released November 12, 2022 + +This release adds the ability to add rate limit exemptions for IP ranges instead of just specific IP addresses. It also fixes +a few bugs in the web app and the CLI and adds lots of new examples and install instructions. + +Thanks to [some love on HN](https://news.ycombinator.com/item?id=33517944), we got so many new ntfy users trying out ntfy +and joining the [chat rooms](https://github.com/binwiederhier/ntfy#chat--forum). **Welcome to the ntfy community to all of you!** +We also got a ton of new **[sponsors and donations](https://github.com/sponsors/binwiederhier)** 💸, which is amazing. I'd like to thank +all of you for believing in the project, and for helping me pay the server cost. The HN spike increased the AWS cost quite a bit. + +**Features:** + +* Allow IP CIDRs in `visitor-request-limit-exempt-hosts` ([#423](https://github.com/binwiederhier/ntfy/issues/423), thanks to [@karmanyaahm](https://github.com/karmanyaahm)) + +**Bug fixes + maintenance:** + +* Subscriptions can now have a display name ([#370](https://github.com/binwiederhier/ntfy/issues/370), thanks to [@tfheen](https://github.com/tfheen) for reporting) +* Bump Go version to Go 18.x ([#422](https://github.com/binwiederhier/ntfy/issues/422)) +* Web: Strip trailing slash when subscribing ([#428](https://github.com/binwiederhier/ntfy/issues/428), thanks to [@raining1123](https://github.com/raining1123) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) +* Web: Strip trailing slash after server URL in publish dialog ([#441](https://github.com/binwiederhier/ntfy/issues/441), thanks to [@wunter8](https://github.com/wunter8)) +* Allow empty passwords in `client.yml` ([#374](https://github.com/binwiederhier/ntfy/issues/374), thanks to [@cyqsimon](https://github.com/cyqsimon) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) +* `ntfy pub` will now use default username and password from `client.yml` ([#431](https://github.com/binwiederhier/ntfy/issues/431), thanks to [@wunter8](https://github.com/wunter8) for fixing) +* Make `ntfy sub` work with `NTFY_USER` env variable ([#447](https://github.com/binwiederhier/ntfy/pull/447), thanks to [SuperSandro2000](https://github.com/SuperSandro2000)) +* Web: Disallow GET/HEAD requests with body in actions ([#468](https://github.com/binwiederhier/ntfy/issues/468), thanks to [@ollien](https://github.com/ollien)) + +**Documentation:** + +* Updated developer docs, bump nodejs and go version ([#414](https://github.com/binwiederhier/ntfy/issues/414), thanks to [@YJSoft](https://github.com/YJSoft) for reporting) +* Officially document `?auth=..` query parameter ([#433](https://github.com/binwiederhier/ntfy/pull/433), thanks to [@wunter8](https://github.com/wunter8)) +* Added Rundeck example ([#427](https://github.com/binwiederhier/ntfy/pull/427), thanks to [@demogorgonz](https://github.com/demogorgonz)) +* Fix Debian installation instructions ([#237](https://github.com/binwiederhier/ntfy/issues/237), thanks to [@Joeharrison94](https://github.com/Joeharrison94) for reporting) +* Updated [example](https://ntfy.sh/docs/examples/#gatus) with official [Gatus](https://github.com/TwiN/gatus) integration (thanks to [@TwiN](https://github.com/TwiN)) +* Added [Kubernetes install instructions](https://ntfy.sh/docs/install/#kubernetes) ([#452](https://github.com/binwiederhier/ntfy/pull/452), thanks to [@gmemstr](https://github.com/gmemstr)) +* Added [additional NixOS links for self-hosting](https://ntfy.sh/docs/install/#nixos-nix) ([#462](https://github.com/binwiederhier/ntfy/pull/462), thanks to [@wamserma](https://github.com/wamserma)) +* Added additional [more secure nginx config example](https://ntfy.sh/docs/config/#nginxapache2caddy) ([#451](https://github.com/binwiederhier/ntfy/pull/451), thanks to [SuperSandro2000](https://github.com/SuperSandro2000)) +* Minor fixes in the config table ([#470](https://github.com/binwiederhier/ntfy/pull/470), thanks to [snh](https://github.com/snh)) +* Fix broken link ([#476](https://github.com/binwiederhier/ntfy/pull/476), thanks to [@shuuji3](https://github.com/shuuji3)) + +**Additional translations:** + +* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/)) + +**Sponsorships:**: + +Thank you to the amazing folks who decided to [sponsor ntfy](https://github.com/sponsors/binwiederhier). Thank you for +helping carry the cost of the public server and developer licenses, and more importantly: Thank you for believing in ntfy! +You guys rock! + +A list of all the sponsors can be found in the [README](https://github.com/binwiederhier/ntfy/blob/main/README.md). + +## ntfy Android app v1.14.0 +Released September 27, 2022 + +This release adds the ability to set a custom icon to each notification, as well as a display name to subscriptions. We +also moved the action buttons in the detail view to a more logical place, fixed a bunch of bugs, and added four more +languages. Hurray! + +**Features:** + +* Subscriptions can now have a display name ([#313](https://github.com/binwiederhier/ntfy/issues/313), thanks to [@wunter8](https://github.com/wunter8)) +* Display name for UnifiedPush subscriptions ([#355](https://github.com/binwiederhier/ntfy/issues/355), thanks to [@wunter8](https://github.com/wunter8)) +* Polling is now done with `since=` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165)) +* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) +* Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8)) +* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8)) + +**Bug fixes:** + +* Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8)) +* Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8)) +* Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting) + +**Additional translations:** + +* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/)) +* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/)) +* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/)) +* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/)) + +Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up some Android tickets, and fixing them! You rock! + +## ntfy server v1.28.0 +Released September 27, 2022 + +This release primarily adds icon support for the Android app, and adds a display name to subscriptions in the web app. +Aside from that, we fixed a few random bugs, most importantly the `Priority` header bug that allows the use behind +Cloudflare. We also added a ton of documentation. Most prominently, an [integrations + projects page](https://ntfy.sh/docs/integrations/). + +As of now, I also have started accepting **[donations and sponsorships](https://github.com/sponsors/binwiederhier)** 💸. +I would be very humbled if you consider donating. + +**Features:** + +* Subscription display name for the web app ([#348](https://github.com/binwiederhier/ntfy/pull/348)) +* Allow setting socket permissions via `--listen-unix-mode` ([#356](https://github.com/binwiederhier/ntfy/pull/356), thanks to [@koro666](https://github.com/koro666)) +* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8)) +* CLI: Allow default username/password in `client.yml` ([#372](https://github.com/binwiederhier/ntfy/pull/372), thanks to [@wunter8](https://github.com/wunter8)) +* Build support for other Unix systems ([#393](https://github.com/binwiederhier/ntfy/pull/393), thanks to [@la-ninpre](https://github.com/la-ninpre)) + +**Bug fixes:** + +* `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting) +* Ignore new draft HTTP `Priority` header ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting) +* Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket) +* Better logging for Matrix push key errors ([#384](https://github.com/binwiederhier/ntfy/pull/384), thanks to [@christophehenry](https://github.com/christophehenry)) +* Web: Switched "Pop" and "Pop Swoosh" sounds ([#352](https://github.com/binwiederhier/ntfy/issues/352), thanks to [@coma-toast](https://github.com/coma-toast) for reporting) + +**Documentation:** + +* Added [integrations + projects page](https://ntfy.sh/docs/integrations/) (**so many integrations, whoa!**) +* Added example for [UptimeRobot](https://ntfy.sh/docs/examples/#uptimerobot) +* Fix some PowerShell publish docs ([#345](https://github.com/binwiederhier/ntfy/pull/345), thanks to [@noahpeltier](https://github.com/noahpeltier)) +* Clarified Docker install instructions ([#361](https://github.com/binwiederhier/ntfy/issues/361), thanks to [@barart](https://github.com/barart) for reporting) +* Mismatched quotation marks ([#392](https://github.com/binwiederhier/ntfy/pull/392)], thanks to [@connorlanigan](https://github.com/connorlanigan)) + +**Additional translations:** + +* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/)) +* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/)) + +## ntfy server v1.27.2 +Released June 23, 2022 + +This release brings two new CLI options to wait for a command to finish, or for a PID to exit. It also adds more detail +to trace debug output. Aside from other bugs, it fixes a performance issue that occurred in large installations every +minute or so, due to competing stats gathering (personal installations will likely be unaffected by this). + +**Features:** + +* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#wal-for-message-cache) (no ticket) +* ntfy CLI can now [wait for a command or PID](subscribe/cli.md#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea) +* Trace: Log entire HTTP request to simplify debugging (no ticket) +* Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3)) + +**Bug fixes:** + +* Fix slow requests due to excessive locking ([#338](https://github.com/binwiederhier/ntfy/issues/338)) +* Return HTTP 500 for `GET /_matrix/push/v1/notify` when `base-url` is not configured (no ticket) +* Disallow setting `upstream-base-url` to the same value as `base-url` ([#334](https://github.com/binwiederhier/ntfy/issues/334), thanks to [@oester](https://github.com/oester) for reporting) +* Fix `since=` implementation for multiple topics ([#336](https://github.com/binwiederhier/ntfy/issues/336), thanks to [@karmanyaahm](https://github.com/karmanyaahm) for reporting) +* Simple parsing in `Actions` header now supports settings Android `intent=` key ([#341](https://github.com/binwiederhier/ntfy/pull/341), thanks to [@wunter8](https://github.com/wunter8)) + +**Deprecations:** + +* The `ntfy publish --env-topic` option is deprecated as of now (see [deprecations](deprecations.md) for details) + +## ntfy server v1.26.0 +Released June 16, 2022 + +This release adds a Matrix Push Gateway directly into ntfy, to make self-hosting a Matrix server easier. The Windows +CLI is now available via Scoop, and ntfy is now natively supported in Uptime Kuma. + +**Features:** + +* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting) +* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu)) +* [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann)) +* Display ntfy version in `ntfy serve` command ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs)) + +**Bug fixes:** + +* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) +* Use last address in `X-Forwarded-For` header as visitor address ([#328](https://github.com/binwiederhier/ntfy/issues/328)) + +**Documentation** + +* Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann)) +* Fix Docker install instructions ([#320](https://github.com/binwiederhier/ntfy/issues/320), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) +* Add clarifying comments to base-url ([#322](https://github.com/binwiederhier/ntfy/issues/322), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) +* Update FAQ for iOS app ([#321](https://github.com/binwiederhier/ntfy/issues/321), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting) + +## ntfy iOS app v1.2 +Released June 16, 2022 + +This release adds support for authentication/authorization for self-hosted servers. It also allows you to +set your server as the default server for new topics. + +**Features:** + +* Support for auth and user management ([#277](https://github.com/binwiederhier/ntfy/issues/277)) +* Ability to add default server ([#295](https://github.com/binwiederhier/ntfy/issues/295)) + +**Bug fixes:** + +* Add validation for selfhosted server URL ([#290](https://github.com/binwiederhier/ntfy/issues/290)) + +## ntfy server v1.25.2 +Released June 2, 2022 + +This release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a +production problem with a few over-users that resulted in Firebase quota problems (only applying to the over-users). +We now block visitors from using Firebase if they trigger a quota exceeded response. + +On top of that, we updated the Firebase SDK and are now building the release in GitHub Actions. We've also got two +more translations: Chinese/Simplified and Dutch. + +**Features:** + +* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284)) + +**Bugs**: + +* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289)) +* Fix documentation header blue header due to mkdocs-material theme update (no ticket) + +**Maintenance:** + +* Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274)) +* CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36)) + +**Documentation**: + +* ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket) +* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs)) +* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s)) +* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting) +* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl)) + +**Additional translations:** + +* Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/)) +* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/)) + +## ntfy iOS app v1.1 +Released May 31, 2022 + +In this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis, +action buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click +action when the notification is tapped, and various other fixes. + +It also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be +configured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) +for details). + +**Features:** + +* [Message priority](https://ntfy.sh/docs/publish/#message-priority) support (no ticket) +* [Tags/emojis](https://ntfy.sh/docs/publish/#tags-emojis) support (no ticket) +* [Action buttons](https://ntfy.sh/docs/publish/#action-buttons) support (no ticket) +* [Click action](https://ntfy.sh/docs/publish/#click-action) support (no ticket) +* Open topic when notification clicked (no ticket) +* Notification now makes a sound and vibrates (no ticket) +* Cancel notifications when navigating to topic (no ticket) +* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99)) + +**Bug fixes:** + +* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)) + +## ntfy server v1.24.0 +Released May 28, 2022 + +This release of the ntfy server brings supporting features for the ntfy iOS app. Most importantly, it +enables support for self-hosted servers in combination with the iOS app. This is to overcome the restrictive +Apple development environment. + +**Features:** + +* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket) +* Add subscribe filter to query exact messages by ID (no ticket) +* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket) + +**Bug fixes:** + +* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall)) + +**Additional translations:** + +* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/)) + +## ntfy iOS app v1.0 +Released May 25, 2022 + +This is the first version of the ntfy iOS app. It supports only ntfy.sh (no selfhosted servers) and only messages + title +(no priority, tags, attachments, ...). I'll rapidly add (hopefully) most of the other ntfy features, and then I'll focus +on self-hosted servers. + +The app is now available in the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). + +**Tickets:** + +* iOS app ([#4](https://github.com/binwiederhier/ntfy/issues/4), see also: [TestFlight summary](https://github.com/binwiederhier/ntfy/issues/4#issuecomment-1133767150)) + +**Thanks:** + +* Thank you to all the testers who tried out the app. You guys gave me the confidence that it's ready to release (albeit with + some known issues which will be addressed in follow-up releases). + +## ntfy server v1.23.0 +Released May 21, 2022 + +This release ships a CLI for Windows and macOS, as well as the ability to disable the web app entirely. On top of that, +it adds support for APNs, the iOS messaging service. This is needed for the (soon to be released) iOS app. + +**Features:** + +* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112)) +* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid)) +* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia)) + +**Bug fixes:** + +* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado)) +* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama)) + +**Documentation:** + +* Typo in install instructions ([#252](https://github.com/binwiederhier/ntfy/pull/252)/[#251](https://github.com/binwiederhier/ntfy/issues/251), thanks to [@oddlama](https://github.com/oddlama)) +* Fix typo in private server example ([#262](https://github.com/binwiederhier/ntfy/pull/262), thanks to [@MayeulC](https://github.com/MayeulC)) +* [Examples](examples.md) for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) ([#264](https://github.com/binwiederhier/ntfy/pull/264), thanks to [@Fallenbagel](https://github.com/Fallenbagel)) + +**Additional translations:** + +* Portuguese/Brazil (thanks to [@tiagotriques](https://hosted.weblate.org/user/tiagotriques/) and [@pireshenrique22](https://hosted.weblate.org/user/pireshenrique22/)) + +Thank you to the many translators, who helped translate the new strings so quickly. I am humbled and amazed by your help. + +## ntfy Android app v1.13.0 +Released May 11, 2022 + +This release brings a slightly altered design for the detail view, featuring a card layout to make notifications more easily +distinguishable from one another. It also ships per-topic settings that allow overriding minimum priority, auto delete threshold +and custom icons. Aside from that, we've got tons of bug fixes as usual. + +**Features:** + +* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting) +* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting) + +**Bug fixes:** + +* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting) +* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting) +* Restoring topics or settings from backup doesn't work ([#223](https://github.com/binwiederhier/ntfy/issues/223), thanks to [@shadow00](https://github.com/shadow00) for reporting) +* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting) +* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting) +* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233)) +* Prevent long topic names and icons from overlapping ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting) + +**Additional translations:** + +* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/)) + +**Thank you:** + +Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and +to [@Joeharrison94](https://github.com/Joeharrison94) for the input. And thank you very much to all the translators for catching up so quickly. + +## ntfy server v1.22.0 +Released May 7, 2022 + +This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app. +It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter. + +We've also improved the documentation a little and added translations for three more languages. + +**Features:** + +* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217)) +* Better parsing of the user actions, allowing quotes (no ticket) +* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8)) + +**Bug fixes:** + +* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it) +* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222)) +* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting) + +**Documentation:** + +* Improved caddy configuration (no ticket, thanks to @Stnby) +* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable)) +* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW)) + +**Additional translations:** + +* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/)) +* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/)) +* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/)) + +**Thanks for testing:** + +Thanks to [@wunter8](https://github.com/wunter8) for testing. + +## ntfy Android app v1.12.0 +Released Apr 25, 2022 + +The main feature in this Android release is [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons), a feature +that allows users to add actions to the notifications. Actions can be to view a website or app, send a broadcast, or +send a HTTP request. + +We also added support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links), added three more +languages and fixed a ton of bugs. + +**Features:** + +* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134), + thanks to [@mrherman](https://github.com/mrherman) for reporting) +* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks + to [@Copephobia](https://github.com/Copephobia) for reporting) +* [Fastlane metadata](https://hosted.weblate.org/projects/ntfy/android-fastlane/) can now be translated too ([#198](https://github.com/binwiederhier/ntfy/issues/198), + thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting) +* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91)) + +**Bug fixes:** + +* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193), + thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting) +* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209), + thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting) +* "[x] Instant delivery in doze mode" checkbox does not work properly ([#211](https://github.com/binwiederhier/ntfy/issues/211)) +* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to + [@cmeis](https://github.com/cmeis) for reporting) +* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to + [@cmeis](https://github.com/cmeis) for reporting) +* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to + [@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting) + +**Additional translations:** + +* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/)) +* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/)) +* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/)) +* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/)) + +**Thanks for testing:** + +Thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) (aka @Shard), [@cmeis](https://github.com/cmeis), +@poblabs, and everyone I forgot for testing. + +## ntfy server v1.21.2 +Released Apr 24, 2022 + +In this release, the web app got translation support and was translated into 9 languages already 🇧🇬 🇩🇪 🇺🇸 🌎. +It also re-adds support for ARMv6, and adds server-side support for Action Buttons. [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons) +is a feature that will be released in the Android app soon. It allows users to add actions to the notifications. +Limited support is available in the web app. + +**Features:** + +* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134), + thanks to [@mrherman](https://github.com/mrherman) for reporting) +* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting) +* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189)) + +**Bug fixes:** + +* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) +* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis)) +* Web app: basic URL validation in user management ([#204](https://github.com/binwiederhier/ntfy/issues/204), thanks to [@cmeis](https://github.com/cmeis)) +* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to + [@cmeis](https://github.com/cmeis) for reporting) + +**Translations (web app):** + +* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) +* German (thanks to [@cmeis](https://github.com/cmeis)) +* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/)) +* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/)) +* Norwegian Bokmål (thanks to [@comradekingu](https://github.com/comradekingu)) +* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/)) +* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh)) +* Turkish (thanks to [@ersen](https://ersen.moe/)) + +**Integrations:** + +[Apprise](https://github.com/caronc/apprise) support was fully released in [v0.9.8.2](https://github.com/caronc/apprise/releases/tag/v0.9.8.2) +of Apprise. Thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work. +You can try it yourself like this (detailed usage in the [Apprise wiki](https://github.com/caronc/apprise/wiki/Notify_ntfy)): + +``` +pip3 install apprise +apprise -b "Hi there" ntfys://mytopic +``` + +## ntfy Android app v1.11.0 +Released Apr 7, 2022 + +**Features:** + +* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181)) +* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142)) +* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to + [@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things) + +**Bug fixes:** + +* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting) +* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185)) +* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting) +* Add priority strings to strings.xml to make it translatable (#192, thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) + +**Translations:** + +* English language improvements (thanks to [@comradekingu](https://github.com/comradekingu)) +* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) +* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy)) +* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony)) +* French (thanks to [@Kusoneko](https://kusoneko.moe/) and [@mlcsthor](https://hosted.weblate.org/user/mlcsthor/)) +* German (thanks to [@cmeis](https://github.com/cmeis)) +* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/)) +* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/)) +* Norwegian Bokmål (*incomplete*, thanks to [@comradekingu](https://github.com/comradekingu)) +* Portuguese/Brazil (thanks to [@LW](https://hosted.weblate.org/user/LW/)) +* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh)) +* Turkish (thanks to [@ersen](https://ersen.moe/)) + +**Thanks:** + +* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94), + and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release + +## ntfy server v1.20.0 +Released Apr 6, 2022 + +**Features:**: + +* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196)) + +**Bug fixes:** + +* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d)) + +**Documentation:** + +* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d)) + +**Integrations:** + +* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524), + thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work) + +## ntfy server v1.19.0 +Released Mar 30, 2022 + +**Bug fixes:** + +* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos)) +* Do not allow comma in topic name in publish via GET endpoint (no ticket) +* Add "Access-Control-Allow-Origin: *" for attachments (no ticket, thanks to @FrameXX) +* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186)) +* Added missing params `delay` and `email` to publish as JSON body (no ticket) + +**Documentation:** + +* Improved [e-mail publishing](config.md#e-mail-publishing) documentation + +## ntfy server v1.18.1 +Released Mar 21, 2022 +_This release ships no features or bug fixes. It's merely a documentation update._ + +**Documentation:** + +* Overhaul of [developer documentation](https://ntfy.sh/docs/develop/) +* PowerShell examples for [publish documentation](https://ntfy.sh/docs/publish/) ([#138](https://github.com/binwiederhier/ntfy/issues/138), thanks to [@Joeharrison94](https://github.com/Joeharrison94)) +* Additional examples for [NodeRED, Gatus, Sonarr, Radarr, ...](https://ntfy.sh/docs/examples/) (thanks to [@nickexyz](https://github.com/nickexyz)) +* Fixes in developer instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting) + +## ntfy Android app v1.10.0 +Released Mar 21, 2022 + +**Features:** + +* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130)) +* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting) +* Open "Click" link when tapping notification ([#110](https://github.com/binwiederhier/ntfy/issues/110), thanks [@cmeis](https://github.com/cmeis) for reporting) +* JSON stream deprecation banner ([#164](https://github.com/binwiederhier/ntfy/issues/164)) + +**Bug fixes:** + +* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting) + +## ntfy server v1.18.0 +Released Mar 16, 2022 + +**Features:** + +* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133), + thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and + [@Fallenbagel](https://github.com/Fallenbagel) for testing) + +**Bug fixes:** + +* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting) +* Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert)) +* Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares)) + +**Deprecations:** + +* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md) + +## ntfy server v1.17.1 +Released Mar 12, 2022 + +**Bug fixes:** + +* Replace `crypto.subtle` with `hashCode` to errors with Brave/FF-Windows (#157, thanks for reporting @arminus) + +## ntfy server v1.17.0 +Released Mar 11, 2022 + +**Features & bug fixes:** + +* Replace [web app](https://ntfy.sh/app) with a React/MUI-based web app from the 21st century (#111) +* Web UI broken with auth (#132, thanks for reporting @arminus) +* Send static web resources as `Content-Encoding: gzip`, i.e. docs and web app (no ticket) +* Add support for auth via `?auth=...` query param, used by WebSocket in web app (no ticket) + +## ntfy server v1.16.0 +Released Feb 27, 2022 + +**Features & Bug fixes:** + +* Add [auth support](https://ntfy.sh/docs/subscribe/cli/#authentication) for subscribing with CLI (#147/#148, thanks @lrabane) +* Add support for [?since=](https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages) (#151, thanks for reporting @nachotp) + +**Documentation:** + +* Add [watchtower/shoutrr examples](https://ntfy.sh/docs/examples/#watchtower-notifications-shoutrrr) (#150, thanks @rogeliodh) +* Add [release notes](https://ntfy.sh/docs/releases/) + +**Technical notes:** + +* As of this release, message IDs will be 12 characters long (as opposed to 10 characters). This is to be able to + distinguish them from Unix timestamps for #151. + +## ntfy Android app v1.9.1 +Released Feb 16, 2022 + +**Features:** + +* Share to topic feature (#131, thanks u/emptymatrix for reporting) +* Ability to pick a default server (#127, thanks to @poblabs for reporting and testing) +* Automatically delete notifications (#71, thanks @arjan-s for reporting) +* Dark theme: Improvements around style and contrast (#119, thanks @kzshantonu for reporting) + +**Bug fixes:** + +* Do not attempt to download attachments if they are already expired (#135) +* Fixed crash in AddFragment as seen per stack trace in Play Console (no ticket) + +**Other thanks:** + +* Thanks to @rogeliodh, @cmeis and @poblabs for testing + +## ntfy server v1.15.0 +Released Feb 14, 2022 + +**Features & bug fixes:** + +* Compress binaries with `upx` (#137) +* Add `visitor-request-limit-exempt-hosts` to exempt friendly hosts from rate limits (#144) +* Double default requests per second limit from 1 per 10s to 1 per 5s (no ticket) +* Convert `\n` to new line for `X-Message` header as prep for sharing feature (see #136) +* Reduce bcrypt cost to 10 to make auth timing more reasonable on slow servers (no ticket) +* Docs update to include [public test topics](https://ntfy.sh/docs/publish/#public-topics) (no ticket) + +## ntfy server v1.14.1 +Released Feb 9, 2022 + +**Bug fixes:** + +* Fix ARMv8 Docker build (#113, thanks to @djmaze) +* No other significant changes + +## ntfy Android app v1.8.1 +Released Feb 6, 2022 + +**Features:** + +* Support [auth / access control](https://ntfy.sh/docs/config/#access-control) (#19, thanks to @cmeis, @drsprite/@poblabs, + @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher) +* Export/upload log now allows censored/uncensored logs (no ticket) +* Removed wake lock (except for notification dispatching, no ticket) +* Swipe to remove notifications (#117) + +**Bug fixes:** + +* Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob) +* Fix for Android 12 crashes (#124, thanks @eskilop) +* Fix WebSocket retry logic bug with multiple servers (no ticket) +* Fix race in refresh logic leading to duplicate connections (no ticket) +* Fix scrolling issue in subscribe to topic dialog (#131, thanks @arminus) +* Fix base URL text field color in dark mode, and size with large fonts (no ticket) +* Fix action bar color in dark mode (make black, no ticket) + +**Notes:** + +* Foundational work for per-subscription settings + +## ntfy server v1.14.0 +Released Feb 3, 2022 + +**Features**: + +* Server-side for [authentication & authorization](https://ntfy.sh/docs/config/#access-control) (#19, thanks for testing @cmeis, and for input from @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher) +* Support `NTFY_TOPIC` env variable in `ntfy publish` (#103) + +**Bug fixes**: + +* Binary UnifiedPush messages should not be converted to attachments (part 1, #101) + +**Docs**: + +* Clarification regarding attachments (#118, thanks @xnumad) + +## ntfy Android app v1.7.1 +Released Jan 21, 2022 + +**New features:** + +* Battery improvements: wakelock disabled by default (#76) +* Dark mode: Allow changing app appearance (#102) +* Report logs: Copy/export logs to help troubleshooting (#94) +* WebSockets (experimental): Use WebSockets to subscribe to topics (#96, #100, #97) +* Show battery optimization banner (#105) + +**Bug fixes:** + +* (Partial) support for binary UnifiedPush messages (#101) + +**Notes:** + +* The foreground wakelock is now disabled by default +* The service restarter is now scheduled every 3h instead of every 6h + +## ntfy server v1.13.0 +Released Jan 16, 2022 + +**Features:** + +* [Websockets](https://ntfy.sh/docs/subscribe/api/#websockets) endpoint +* Listen on Unix socket, see [config option](https://ntfy.sh/docs/config/#config-options) `listen-unix` + +## ntfy Android app v1.6.0 +Released Jan 14, 2022 + +**New features:** + +* Attachments: Send files to the phone (#25, #15) +* Click action: Add a click action URL to notifications (#85) +* Battery optimization: Allow disabling persistent wake-lock (#76, thanks @MatMaul) +* Recognize imported user CA certificate for self-hosted servers (#87, thanks @keith24) +* Remove mentions of "instant delivery" from F-Droid to make it less confusing (no ticket) + +**Bug fixes:** + +* Subscription "muted until" was not always respected (#90) +* Fix two stack traces reported by Play console vitals (no ticket) +* Truncate FCM messages >4,000 bytes, prefer instant messages (#84) + +## ntfy server v1.12.1 +Released Jan 14, 2022 + +**Bug fixes:** + +* Fix security issue with attachment peaking (#93) + +## ntfy server v1.12.0 +Released Jan 13, 2022 + +**Features:** + +* [Attachments](https://ntfy.sh/docs/publish/#attachments) (#25, #15) +* [Click action](https://ntfy.sh/docs/publish/#click-action) (#85) +* Increase FCM priority for high/max priority messages (#70) + +**Bug fixes:** + +* Make postinst script work properly for rpm-based systems (#83, thanks @cmeis) +* Truncate FCM messages longer than 4000 bytes (#84) +* Fix `listen-https` port (no ticket) + +## ntfy Android app v1.5.2 +Released Jan 3, 2022 + +**New features:** + +* Allow using ntfy as UnifiedPush distributor (#9) +* Support for longer message up to 4096 bytes (#77) +* Minimum priority: show notifications only if priority X or higher (#79) +* Allowing disabling broadcasts in global settings (#80) + +**Bug fixes:** + +* Allow int/long extras for SEND_MESSAGE intent (#57) +* Various battery improvement fixes (#76) + +## ntfy server v1.11.2 +Released Jan 1, 2022 + +**Features & bug fixes:** + +* Increase message limit to 4096 bytes (4k) #77 +* Docs for [UnifiedPush](https://unifiedpush.org) #9 +* Increase keepalive interval to 55s #76 +* Increase Firebase keepalive to 3 hours #76 + +## ntfy server v1.10.0 +Released Dec 28, 2021 + +**Features & bug fixes:** + +* [Publish messages via e-mail](publish.md#e-mail-publishing) #66 +* Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64 +* Fixing the Santa bug #65 + +## Older releases +For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) +and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). + +## Not released yet + +### ntfy server v2.8.0 (UNRELEASED) + +**Bug fixes + maintenance:** + +* Fix ACL issue with topic patterns containing underscores ([#840](https://github.com/binwiederhier/ntfy/issues/840), thanks to [@Joe-0237](https://github.com/Joe-0237) for reporting) +* Re-add `tzdata` to Docker images for amd64 image ([#894](https://github.com/binwiederhier/ntfy/issues/894), [#307](https://github.com/binwiederhier/ntfy/pull/307)) +* Add special logic to ignore `Priority` header if it resembled a RFC 9218 value ([#851](https://github.com/binwiederhier/ntfy/pull/851)/[#895](https://github.com/binwiederhier/ntfy/pull/895), thanks to [@gusdleon](https://github.com/gusdleon), see also [#351](https://github.com/binwiederhier/ntfy/issues/351), [#353](https://github.com/binwiederhier/ntfy/issues/353), [#461](https://github.com/binwiederhier/ntfy/issues/461)) +* PWA: hide install prompt on macOS 14 Safari ([#899](https://github.com/binwiederhier/ntfy/pull/899), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves)) +* Fix web app crash in Edge for languages with underline in locale ([#922](https://github.com/binwiederhier/ntfy/pull/922)/[#912](https://github.com/binwiederhier/ntfy/issues/912)/[#852](https://github.com/binwiederhier/ntfy/issues/852), thanks to [@imkero](https://github.com/imkero)) + +### ntfy Android app v1.16.1 (UNRELEASED) + +**Features:** + +* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) + +**Bug fixes + maintenance:** + +* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8)) +* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing) +* Bumped all dependencies to the latest versions (no ticket) + +**Additional languages:** + +* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/)) diff --git a/docs/static/audio/ntfy-phone-call.mp3 b/docs/static/audio/ntfy-phone-call.mp3 new file mode 100644 index 00000000..0cace65f Binary files /dev/null and b/docs/static/audio/ntfy-phone-call.mp3 differ diff --git a/docs/static/audio/ntfy-phone-call.ogg b/docs/static/audio/ntfy-phone-call.ogg new file mode 100644 index 00000000..cbbf6b60 Binary files /dev/null and b/docs/static/audio/ntfy-phone-call.ogg differ diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css new file mode 100644 index 00000000..3c53aed6 --- /dev/null +++ b/docs/static/css/extra.css @@ -0,0 +1,216 @@ +:root > * { + --md-primary-fg-color: #338574; + --md-primary-fg-color--light: #338574; + --md-primary-fg-color--dark: #338574; + --md-footer-bg-color: #353744; + --md-text-font: "Roboto"; + --md-code-font: "Roboto Mono"; +} + +.md-header__button.md-logo :is(img, svg) { + width: unset !important; +} + +.md-header__topic:first-child { + font-weight: 400; +} + +.md-typeset h4 { + font-weight: 500 !important; + margin: 0 !important; + font-size: 1.1em !important; +} + +.admonition { + font-size: .74rem !important; +} + +article { + padding-bottom: 50px; +} + +figure img, figure video { + border-radius: 7px; +} + +header { + background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); +} + +body[data-md-color-scheme="default"] header { + filter: drop-shadow(0 5px 10px #ccc); +} + +body[data-md-color-scheme="slate"] header { + filter: drop-shadow(0 5px 10px #333); +} + +body[data-md-color-scheme="default"] figure img, +body[data-md-color-scheme="default"] figure video, +body[data-md-color-scheme="default"] .screenshots img, +body[data-md-color-scheme="default"] .screenshots video { + filter: drop-shadow(3px 3px 3px #ccc); +} + +body[data-md-color-scheme="slate"] figure img, +body[data-md-color-scheme="slate"] figure video, +body[data-md-color-scheme="slate"] .screenshots img, +body[data-md-color-scheme="slate"] .screenshots video { + filter: drop-shadow(3px 3px 3px #353744); +} + +figure video { + width: 100%; + max-height: 450px; +} + +.remove-md-box { + background: none; + border: none; + margin: 0 auto; +} + +.remove-md-box td { + padding: 0 10px; +} + +.emoji-table .c { + vertical-align: middle !important; +} + +.emoji-table .e { + font-size: 2.5em; + padding: 0 2px !important; + text-align: center !important; + vertical-align: middle !important; +} + +/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */ + +.screenshots { + text-align: center; +} + +.screenshots img { + max-height: 230px; + max-width: 300px; + margin: 3px; + border-radius: 5px; + filter: drop-shadow(2px 2px 2px #ddd); +} + +.screenshots .nowrap { + white-space: nowrap; +} + +.lightbox { + opacity: 0; + visibility: hidden; + position: fixed; + left:0; + right: 0; + top: 0; + bottom: 0; + z-index: -1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease-in; +} + +.lightbox.show { + background-color: rgba(0,0,0, 0.75); + opacity: 1; + visibility: visible; + z-index: 1000; +} + +.lightbox img { + max-width: 90%; + max-height: 90%; + filter: drop-shadow(5px 5px 10px #222); + border-radius: 5px; +} + +.lightbox .close-lightbox { + cursor: pointer; + position: absolute; + top: 30px; + right: 30px; + width: 20px; + height: 20px; +} + +.lightbox .close-lightbox::after, +.lightbox .close-lightbox::before { + content: ''; + width: 3px; + height: 20px; + background-color: #ddd; + position: absolute; + border-radius: 5px; + transform: rotate(45deg); +} + +.lightbox .close-lightbox::before { + transform: rotate(-45deg); +} + +.lightbox .close-lightbox:hover::after, +.lightbox .close-lightbox:hover::before { + background-color: #fff; +} + +/* roboto-300 - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2'); +} + +/* roboto-regular - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2'); +} + +/* roboto-italic - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: italic; + font-weight: 400; + src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2'); +} + +/* roboto-500 - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2'); +} + +/* roboto-700 - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2'); +} + +/* roboto-mono - latin */ +@font-face { + font-display: swap; + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2'); +} diff --git a/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 new file mode 100644 index 00000000..f8894bab Binary files /dev/null and b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-300.woff2 b/docs/static/fonts/roboto-v30-latin-300.woff2 new file mode 100644 index 00000000..60681387 Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-300.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-500.woff2 b/docs/static/fonts/roboto-v30-latin-500.woff2 new file mode 100644 index 00000000..29342a8d Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-500.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-700.woff2 b/docs/static/fonts/roboto-v30-latin-700.woff2 new file mode 100644 index 00000000..771fbecc Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-700.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-italic.woff2 b/docs/static/fonts/roboto-v30-latin-italic.woff2 new file mode 100644 index 00000000..e1b7a79f Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-italic.woff2 differ diff --git a/docs/static/fonts/roboto-v30-latin-regular.woff2 b/docs/static/fonts/roboto-v30-latin-regular.woff2 new file mode 100644 index 00000000..020729ef Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-regular.woff2 differ diff --git a/docs/static/img/android-screenshot-add-instant.png b/docs/static/img/android-screenshot-add-instant.png new file mode 100644 index 00000000..3c6c58c5 Binary files /dev/null and b/docs/static/img/android-screenshot-add-instant.png differ diff --git a/docs/static/img/android-screenshot-add-other.png b/docs/static/img/android-screenshot-add-other.png new file mode 100644 index 00000000..cd4a43b7 Binary files /dev/null and b/docs/static/img/android-screenshot-add-other.png differ diff --git a/docs/static/img/android-screenshot-add.png b/docs/static/img/android-screenshot-add.png new file mode 100644 index 00000000..c85b1faa Binary files /dev/null and b/docs/static/img/android-screenshot-add.png differ diff --git a/docs/static/img/android-screenshot-attachment-file.png b/docs/static/img/android-screenshot-attachment-file.png new file mode 100644 index 00000000..f151c936 Binary files /dev/null and b/docs/static/img/android-screenshot-attachment-file.png differ diff --git a/docs/static/img/android-screenshot-attachment-image.png b/docs/static/img/android-screenshot-attachment-image.png new file mode 100644 index 00000000..42afba8b Binary files /dev/null and b/docs/static/img/android-screenshot-attachment-image.png differ diff --git a/docs/static/img/android-screenshot-basic-notification.png b/docs/static/img/android-screenshot-basic-notification.png new file mode 100644 index 00000000..3a8a245d Binary files /dev/null and b/docs/static/img/android-screenshot-basic-notification.png differ diff --git a/docs/static/img/android-screenshot-detail.png b/docs/static/img/android-screenshot-detail.png new file mode 100644 index 00000000..bbb924c1 Binary files /dev/null and b/docs/static/img/android-screenshot-detail.png differ diff --git a/docs/static/img/android-screenshot-icon.png b/docs/static/img/android-screenshot-icon.png new file mode 100644 index 00000000..68c57a16 Binary files /dev/null and b/docs/static/img/android-screenshot-icon.png differ diff --git a/docs/static/img/android-screenshot-logs.jpg b/docs/static/img/android-screenshot-logs.jpg new file mode 100644 index 00000000..e5f1d8e8 Binary files /dev/null and b/docs/static/img/android-screenshot-logs.jpg differ diff --git a/docs/static/img/android-screenshot-macrodroid-action.png b/docs/static/img/android-screenshot-macrodroid-action.png new file mode 100644 index 00000000..7bf2c082 Binary files /dev/null and b/docs/static/img/android-screenshot-macrodroid-action.png differ diff --git a/docs/static/img/android-screenshot-macrodroid-overview.png b/docs/static/img/android-screenshot-macrodroid-overview.png new file mode 100644 index 00000000..5165943c Binary files /dev/null and b/docs/static/img/android-screenshot-macrodroid-overview.png differ diff --git a/docs/static/img/android-screenshot-macrodroid-send-action.png b/docs/static/img/android-screenshot-macrodroid-send-action.png new file mode 100644 index 00000000..81fc0c18 Binary files /dev/null and b/docs/static/img/android-screenshot-macrodroid-send-action.png differ diff --git a/docs/static/img/android-screenshot-macrodroid-send-macro.png b/docs/static/img/android-screenshot-macrodroid-send-macro.png new file mode 100644 index 00000000..2a301766 Binary files /dev/null and b/docs/static/img/android-screenshot-macrodroid-send-macro.png differ diff --git a/docs/static/img/android-screenshot-macrodroid-trigger.png b/docs/static/img/android-screenshot-macrodroid-trigger.png new file mode 100644 index 00000000..357802bf Binary files /dev/null and b/docs/static/img/android-screenshot-macrodroid-trigger.png differ diff --git a/docs/static/img/android-screenshot-main.png b/docs/static/img/android-screenshot-main.png new file mode 100644 index 00000000..3fdbcb9d Binary files /dev/null and b/docs/static/img/android-screenshot-main.png differ diff --git a/docs/static/img/android-screenshot-muted.png b/docs/static/img/android-screenshot-muted.png new file mode 100644 index 00000000..b9c740e5 Binary files /dev/null and b/docs/static/img/android-screenshot-muted.png differ diff --git a/docs/static/img/android-screenshot-notification-actions.png b/docs/static/img/android-screenshot-notification-actions.png new file mode 100644 index 00000000..f0e568d5 Binary files /dev/null and b/docs/static/img/android-screenshot-notification-actions.png differ diff --git a/docs/static/img/android-screenshot-notification-details.jpg b/docs/static/img/android-screenshot-notification-details.jpg new file mode 100644 index 00000000..6de151b1 Binary files /dev/null and b/docs/static/img/android-screenshot-notification-details.jpg differ diff --git a/docs/static/img/android-screenshot-notification-multiline.jpg b/docs/static/img/android-screenshot-notification-multiline.jpg new file mode 100644 index 00000000..f6ed6612 Binary files /dev/null and b/docs/static/img/android-screenshot-notification-multiline.jpg differ diff --git a/docs/static/img/android-screenshot-notification-settings.png b/docs/static/img/android-screenshot-notification-settings.png new file mode 100644 index 00000000..25bed4f2 Binary files /dev/null and b/docs/static/img/android-screenshot-notification-settings.png differ diff --git a/docs/static/img/android-screenshot-pause.png b/docs/static/img/android-screenshot-pause.png new file mode 100644 index 00000000..270f3c53 Binary files /dev/null and b/docs/static/img/android-screenshot-pause.png differ diff --git a/docs/static/img/android-screenshot-share-1.jpg b/docs/static/img/android-screenshot-share-1.jpg new file mode 100644 index 00000000..503ce9f9 Binary files /dev/null and b/docs/static/img/android-screenshot-share-1.jpg differ diff --git a/docs/static/img/android-screenshot-share-2.jpg b/docs/static/img/android-screenshot-share-2.jpg new file mode 100644 index 00000000..b408f486 Binary files /dev/null and b/docs/static/img/android-screenshot-share-2.jpg differ diff --git a/docs/static/img/android-screenshot-tasker-action-edit.png b/docs/static/img/android-screenshot-tasker-action-edit.png new file mode 100644 index 00000000..b47a0853 Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-action-edit.png differ diff --git a/docs/static/img/android-screenshot-tasker-action-http-post.png b/docs/static/img/android-screenshot-tasker-action-http-post.png new file mode 100644 index 00000000..aa9cbfa1 Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-action-http-post.png differ diff --git a/docs/static/img/android-screenshot-tasker-event-edit.png b/docs/static/img/android-screenshot-tasker-event-edit.png new file mode 100644 index 00000000..15645cb3 Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-event-edit.png differ diff --git a/docs/static/img/android-screenshot-tasker-profile-send.png b/docs/static/img/android-screenshot-tasker-profile-send.png new file mode 100644 index 00000000..fabbe13b Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-profile-send.png differ diff --git a/docs/static/img/android-screenshot-tasker-profiles.png b/docs/static/img/android-screenshot-tasker-profiles.png new file mode 100644 index 00000000..3d92cd2f Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-profiles.png differ diff --git a/docs/static/img/android-screenshot-tasker-task-edit-post.png b/docs/static/img/android-screenshot-tasker-task-edit-post.png new file mode 100644 index 00000000..f1783259 Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-task-edit-post.png differ diff --git a/docs/static/img/android-screenshot-tasker-task-edit.png b/docs/static/img/android-screenshot-tasker-task-edit.png new file mode 100644 index 00000000..eb4f0248 Binary files /dev/null and b/docs/static/img/android-screenshot-tasker-task-edit.png differ diff --git a/docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg b/docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg new file mode 100644 index 00000000..a4334ad4 Binary files /dev/null and b/docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg differ diff --git a/docs/static/img/android-screenshot-unifiedpush-settings.jpg b/docs/static/img/android-screenshot-unifiedpush-settings.jpg new file mode 100644 index 00000000..bfcc7a54 Binary files /dev/null and b/docs/static/img/android-screenshot-unifiedpush-settings.jpg differ diff --git a/docs/static/img/android-screenshot-unifiedpush-subscription.jpg b/docs/static/img/android-screenshot-unifiedpush-subscription.jpg new file mode 100644 index 00000000..e3a19b84 Binary files /dev/null and b/docs/static/img/android-screenshot-unifiedpush-subscription.jpg differ diff --git a/docs/static/img/android-video-overview.mp4 b/docs/static/img/android-video-overview.mp4 new file mode 100644 index 00000000..cf295099 Binary files /dev/null and b/docs/static/img/android-video-overview.mp4 differ diff --git a/docs/static/img/badge-appstore.png b/docs/static/img/badge-appstore.png new file mode 100644 index 00000000..0b4ce1c0 Binary files /dev/null and b/docs/static/img/badge-appstore.png differ diff --git a/docs/static/img/badge-fdroid.png b/docs/static/img/badge-fdroid.png new file mode 100644 index 00000000..9464d38a Binary files /dev/null and b/docs/static/img/badge-fdroid.png differ diff --git a/docs/static/img/badge-googleplay.png b/docs/static/img/badge-googleplay.png new file mode 100644 index 00000000..36036d8b Binary files /dev/null and b/docs/static/img/badge-googleplay.png differ diff --git a/docs/static/img/cdio-setup.jpg b/docs/static/img/cdio-setup.jpg new file mode 100644 index 00000000..2f9e44cb Binary files /dev/null and b/docs/static/img/cdio-setup.jpg differ diff --git a/docs/static/img/cli-subscribe-video-1.mp4 b/docs/static/img/cli-subscribe-video-1.mp4 new file mode 100644 index 00000000..f7cab25a Binary files /dev/null and b/docs/static/img/cli-subscribe-video-1.mp4 differ diff --git a/docs/static/img/cli-subscribe-video-2.webm b/docs/static/img/cli-subscribe-video-2.webm new file mode 100644 index 00000000..e885dab8 Binary files /dev/null and b/docs/static/img/cli-subscribe-video-2.webm differ diff --git a/docs/static/img/cli-subscribe-video-3.webm b/docs/static/img/cli-subscribe-video-3.webm new file mode 100644 index 00000000..a286e2b9 Binary files /dev/null and b/docs/static/img/cli-subscribe-video-3.webm differ diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico new file mode 100644 index 00000000..857fa54c Binary files /dev/null and b/docs/static/img/favicon.ico differ diff --git a/docs/static/img/foreground-service.png b/docs/static/img/foreground-service.png new file mode 100644 index 00000000..09f34344 Binary files /dev/null and b/docs/static/img/foreground-service.png differ diff --git a/docs/static/img/getting-started-add.png b/docs/static/img/getting-started-add.png new file mode 100644 index 00000000..28309c42 Binary files /dev/null and b/docs/static/img/getting-started-add.png differ diff --git a/docs/static/img/grafana-dashboard.png b/docs/static/img/grafana-dashboard.png new file mode 100644 index 00000000..6cb12c80 Binary files /dev/null and b/docs/static/img/grafana-dashboard.png differ diff --git a/docs/static/img/nodered-message.png b/docs/static/img/nodered-message.png new file mode 100644 index 00000000..46279087 Binary files /dev/null and b/docs/static/img/nodered-message.png differ diff --git a/docs/static/img/nodered-picture.png b/docs/static/img/nodered-picture.png new file mode 100644 index 00000000..413ba8a2 Binary files /dev/null and b/docs/static/img/nodered-picture.png differ diff --git a/docs/static/img/notification-settings.png b/docs/static/img/notification-settings.png new file mode 100644 index 00000000..dad10b2e Binary files /dev/null and b/docs/static/img/notification-settings.png differ diff --git a/docs/static/img/notification-with-tags.png b/docs/static/img/notification-with-tags.png new file mode 100644 index 00000000..81773220 Binary files /dev/null and b/docs/static/img/notification-with-tags.png differ diff --git a/docs/static/img/notification-with-title.png b/docs/static/img/notification-with-title.png new file mode 100644 index 00000000..fd458332 Binary files /dev/null and b/docs/static/img/notification-with-title.png differ diff --git a/docs/static/img/ntfy.png b/docs/static/img/ntfy.png new file mode 100644 index 00000000..6b969a84 Binary files /dev/null and b/docs/static/img/ntfy.png differ diff --git a/docs/static/img/priority-1.svg b/docs/static/img/priority-1.svg new file mode 100644 index 00000000..df6a0a49 --- /dev/null +++ b/docs/static/img/priority-1.svg @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/docs/static/img/priority-2.svg b/docs/static/img/priority-2.svg new file mode 100644 index 00000000..10a89ad1 --- /dev/null +++ b/docs/static/img/priority-2.svg @@ -0,0 +1,43 @@ + + + + + + + diff --git a/docs/static/img/priority-4.svg b/docs/static/img/priority-4.svg new file mode 100644 index 00000000..a1723cf8 --- /dev/null +++ b/docs/static/img/priority-4.svg @@ -0,0 +1,43 @@ + + + + + + + diff --git a/docs/static/img/priority-5.svg b/docs/static/img/priority-5.svg new file mode 100644 index 00000000..2e2c4447 --- /dev/null +++ b/docs/static/img/priority-5.svg @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/docs/static/img/priority-detail-overview.png b/docs/static/img/priority-detail-overview.png new file mode 100644 index 00000000..c9321aa7 Binary files /dev/null and b/docs/static/img/priority-detail-overview.png differ diff --git a/docs/static/img/priority-notification.png b/docs/static/img/priority-notification.png new file mode 100644 index 00000000..31d15152 Binary files /dev/null and b/docs/static/img/priority-notification.png differ diff --git a/docs/static/img/pwa-badge.png b/docs/static/img/pwa-badge.png new file mode 100644 index 00000000..1a22de07 Binary files /dev/null and b/docs/static/img/pwa-badge.png differ diff --git a/docs/static/img/pwa-install-chrome-android-menu.jpg b/docs/static/img/pwa-install-chrome-android-menu.jpg new file mode 100644 index 00000000..1c258d64 Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android-menu.jpg differ diff --git a/docs/static/img/pwa-install-chrome-android-popup.jpg b/docs/static/img/pwa-install-chrome-android-popup.jpg new file mode 100644 index 00000000..90c77c77 Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android-popup.jpg differ diff --git a/docs/static/img/pwa-install-chrome-android.jpg b/docs/static/img/pwa-install-chrome-android.jpg new file mode 100644 index 00000000..7f89503f Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android.jpg differ diff --git a/docs/static/img/pwa-install-firefox-android-menu.jpg b/docs/static/img/pwa-install-firefox-android-menu.jpg new file mode 100644 index 00000000..c0aa59e7 Binary files /dev/null and b/docs/static/img/pwa-install-firefox-android-menu.jpg differ diff --git a/docs/static/img/pwa-install-firefox-android-popup.jpg b/docs/static/img/pwa-install-firefox-android-popup.jpg new file mode 100644 index 00000000..e97a858d Binary files /dev/null and b/docs/static/img/pwa-install-firefox-android-popup.jpg differ diff --git a/docs/static/img/pwa-install-macos-safari-add-to-dock.png b/docs/static/img/pwa-install-macos-safari-add-to-dock.png new file mode 100644 index 00000000..8a780605 Binary files /dev/null and b/docs/static/img/pwa-install-macos-safari-add-to-dock.png differ diff --git a/docs/static/img/pwa-install-safari-ios-add-icon.jpg b/docs/static/img/pwa-install-safari-ios-add-icon.jpg new file mode 100644 index 00000000..175fb8b4 Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-add-icon.jpg differ diff --git a/docs/static/img/pwa-install-safari-ios-button.jpg b/docs/static/img/pwa-install-safari-ios-button.jpg new file mode 100644 index 00000000..c9897c30 Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-button.jpg differ diff --git a/docs/static/img/pwa-install-safari-ios-menu.jpg b/docs/static/img/pwa-install-safari-ios-menu.jpg new file mode 100644 index 00000000..b6408afd Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-menu.jpg differ diff --git a/docs/static/img/pwa-install.png b/docs/static/img/pwa-install.png new file mode 100644 index 00000000..c44e7dbc Binary files /dev/null and b/docs/static/img/pwa-install.png differ diff --git a/docs/static/img/pwa.png b/docs/static/img/pwa.png new file mode 100644 index 00000000..c26f29f1 Binary files /dev/null and b/docs/static/img/pwa.png differ diff --git a/docs/static/img/rundeck.png b/docs/static/img/rundeck.png new file mode 100644 index 00000000..9c5a8a85 Binary files /dev/null and b/docs/static/img/rundeck.png differ diff --git a/docs/static/img/screenshot-curl.png b/docs/static/img/screenshot-curl.png new file mode 100644 index 00000000..aeb5a31a Binary files /dev/null and b/docs/static/img/screenshot-curl.png differ diff --git a/docs/static/img/screenshot-email-publishing-dns.png b/docs/static/img/screenshot-email-publishing-dns.png new file mode 100644 index 00000000..d67b057a Binary files /dev/null and b/docs/static/img/screenshot-email-publishing-dns.png differ diff --git a/docs/static/img/screenshot-email-publishing-gmail.png b/docs/static/img/screenshot-email-publishing-gmail.png new file mode 100644 index 00000000..0d8a58e8 Binary files /dev/null and b/docs/static/img/screenshot-email-publishing-gmail.png differ diff --git a/docs/static/img/screenshot-email.png b/docs/static/img/screenshot-email.png new file mode 100644 index 00000000..48c1f392 Binary files /dev/null and b/docs/static/img/screenshot-email.png differ diff --git a/docs/static/img/screenshot-phone-add.jpg b/docs/static/img/screenshot-phone-add.jpg new file mode 100644 index 00000000..f728ec99 Binary files /dev/null and b/docs/static/img/screenshot-phone-add.jpg differ diff --git a/docs/static/img/screenshot-phone-detail.jpg b/docs/static/img/screenshot-phone-detail.jpg new file mode 100644 index 00000000..2cd3b2fe Binary files /dev/null and b/docs/static/img/screenshot-phone-detail.jpg differ diff --git a/docs/static/img/screenshot-phone-main.jpg b/docs/static/img/screenshot-phone-main.jpg new file mode 100644 index 00000000..5caeee14 Binary files /dev/null and b/docs/static/img/screenshot-phone-main.jpg differ diff --git a/docs/static/img/screenshot-phone-notification.jpg b/docs/static/img/screenshot-phone-notification.jpg new file mode 100644 index 00000000..7924c6fd Binary files /dev/null and b/docs/static/img/screenshot-phone-notification.jpg differ diff --git a/docs/static/img/uptimekuma-ios-down.jpg b/docs/static/img/uptimekuma-ios-down.jpg new file mode 100644 index 00000000..330f86f9 Binary files /dev/null and b/docs/static/img/uptimekuma-ios-down.jpg differ diff --git a/docs/static/img/uptimekuma-ios-test.jpg b/docs/static/img/uptimekuma-ios-test.jpg new file mode 100644 index 00000000..1b678a69 Binary files /dev/null and b/docs/static/img/uptimekuma-ios-test.jpg differ diff --git a/docs/static/img/uptimekuma-ios-up.jpg b/docs/static/img/uptimekuma-ios-up.jpg new file mode 100644 index 00000000..919d9ba1 Binary files /dev/null and b/docs/static/img/uptimekuma-ios-up.jpg differ diff --git a/docs/static/img/uptimekuma-settings.png b/docs/static/img/uptimekuma-settings.png new file mode 100644 index 00000000..5b21bdd6 Binary files /dev/null and b/docs/static/img/uptimekuma-settings.png differ diff --git a/docs/static/img/uptimekuma-setup.png b/docs/static/img/uptimekuma-setup.png new file mode 100644 index 00000000..cd57baa7 Binary files /dev/null and b/docs/static/img/uptimekuma-setup.png differ diff --git a/docs/static/img/uptimerobot-setup.jpg b/docs/static/img/uptimerobot-setup.jpg new file mode 100644 index 00000000..232d5366 Binary files /dev/null and b/docs/static/img/uptimerobot-setup.jpg differ diff --git a/docs/static/img/uptimerobot-test.jpg b/docs/static/img/uptimerobot-test.jpg new file mode 100644 index 00000000..d2fbfaec Binary files /dev/null and b/docs/static/img/uptimerobot-test.jpg differ diff --git a/docs/static/img/web-account.png b/docs/static/img/web-account.png new file mode 100644 index 00000000..48e916e5 Binary files /dev/null and b/docs/static/img/web-account.png differ diff --git a/docs/static/img/web-detail.png b/docs/static/img/web-detail.png new file mode 100644 index 00000000..aabf0dc9 Binary files /dev/null and b/docs/static/img/web-detail.png differ diff --git a/docs/static/img/web-logs.png b/docs/static/img/web-logs.png new file mode 100644 index 00000000..2dfebdcc Binary files /dev/null and b/docs/static/img/web-logs.png differ diff --git a/docs/static/img/web-markdown.png b/docs/static/img/web-markdown.png new file mode 100644 index 00000000..612f2cf3 Binary files /dev/null and b/docs/static/img/web-markdown.png differ diff --git a/docs/static/img/web-notification.png b/docs/static/img/web-notification.png new file mode 100644 index 00000000..535d0830 Binary files /dev/null and b/docs/static/img/web-notification.png differ diff --git a/docs/static/img/web-phone-verify.png b/docs/static/img/web-phone-verify.png new file mode 100644 index 00000000..335aeef1 Binary files /dev/null and b/docs/static/img/web-phone-verify.png differ diff --git a/docs/static/img/web-reserve-topic-dialog.png b/docs/static/img/web-reserve-topic-dialog.png new file mode 100644 index 00000000..e4fb4b4a Binary files /dev/null and b/docs/static/img/web-reserve-topic-dialog.png differ diff --git a/docs/static/img/web-reserve-topic.png b/docs/static/img/web-reserve-topic.png new file mode 100644 index 00000000..a8ffd374 Binary files /dev/null and b/docs/static/img/web-reserve-topic.png differ diff --git a/docs/static/img/web-signup.png b/docs/static/img/web-signup.png new file mode 100644 index 00000000..62c7bd30 Binary files /dev/null and b/docs/static/img/web-signup.png differ diff --git a/docs/static/img/web-subscribe.png b/docs/static/img/web-subscribe.png new file mode 100644 index 00000000..ccbd0493 Binary files /dev/null and b/docs/static/img/web-subscribe.png differ diff --git a/docs/static/img/web-token-create.png b/docs/static/img/web-token-create.png new file mode 100644 index 00000000..29dcb387 Binary files /dev/null and b/docs/static/img/web-token-create.png differ diff --git a/docs/static/img/web-token-list.png b/docs/static/img/web-token-list.png new file mode 100644 index 00000000..b36b2a7f Binary files /dev/null and b/docs/static/img/web-token-list.png differ diff --git a/docs/static/js/extra.js b/docs/static/js/extra.js new file mode 100644 index 00000000..6ddf07a9 --- /dev/null +++ b/docs/static/js/extra.js @@ -0,0 +1,99 @@ +// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs + +const savedCodeTab = localStorage.getItem('savedTab') +const codeTabs = document.querySelectorAll(".tabbed-set > input") +for (const tab of codeTabs) { + tab.addEventListener("click", () => { + const current = document.querySelector(`label[for=${tab.id}]`) + const pos = current.getBoundingClientRect().top + const labelContent = current.innerHTML + const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label') + for (const label of labels) { + if (label.innerHTML === labelContent) { + document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true + } + } + + // Preserve scroll position + const delta = (current.getBoundingClientRect().top) - pos + window.scrollBy(0, delta) + + // Save + localStorage.setItem('savedTab', labelContent) + }) + + // Select saved tab + const current = document.querySelector(`label[for=${tab.id}]`) + const labelContent = current.innerHTML + if (savedCodeTab === labelContent) { + tab.checked = true + } +} + +// Lightbox for screenshot + +const lightbox = document.createElement('div'); +lightbox.classList.add('lightbox'); +document.body.appendChild(lightbox); + +const showScreenshotOverlay = (e, el, group, index) => { + lightbox.classList.add('show'); + document.addEventListener('keydown', nextScreenshotKeyboardListener); + return showScreenshot(e, group, index); +}; + +const showScreenshot = (e, group, index) => { + const actualIndex = resolveScreenshotIndex(group, index); + lightbox.innerHTML = '
' + screenshots[group][actualIndex].innerHTML; + lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); }; + currentScreenshotGroup = group; + currentScreenshotIndex = actualIndex; + e.stopPropagation(); + return false; +}; + +const nextScreenshot = (e) => { + return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1); +}; + +const previousScreenshot = (e) => { + return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1); +}; + +const resolveScreenshotIndex = (group, index) => { + if (index < 0) { + return screenshots[group].length - 1; + } else if (index > screenshots[group].length - 1) { + return 0; + } + return index; +}; + +const hideScreenshotOverlay = (e) => { + lightbox.classList.remove('show'); + document.removeEventListener('keydown', nextScreenshotKeyboardListener); +}; + +const nextScreenshotKeyboardListener = (e) => { + switch (e.keyCode) { + case 37: + previousScreenshot(e); + break; + case 39: + nextScreenshot(e); + break; + } +}; + +let currentScreenshotGroup = ''; +let currentScreenshotIndex = 0; +let screenshots = {}; +Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => { + const group = sg.id; + screenshots[group] = [...sg.querySelectorAll('a')]; + screenshots[group].forEach((el, index) => { + el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); }; + }); +}); + +lightbox.onclick = hideScreenshotOverlay; diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md new file mode 100644 index 00000000..58da9752 --- /dev/null +++ b/docs/subscribe/api.md @@ -0,0 +1,427 @@ +# Subscribe via API +You can create and subscribe to a topic in the [web UI](web.md), via the [phone app](phone.md), via the [ntfy CLI](cli.md), +or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to +check out the page that describes how to [publish messages](../publish.md). + +You can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or +**[via WebSockets](#websockets)**. Both are incredibly simple to use. + +## HTTP stream +The HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and +the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which +only differ in the response format: + +* [JSON stream](#subscribe-as-json-stream): `/json` returns a JSON stream, with one JSON message object per line +* [SSE stream](#subscribe-as-sse-stream): `/sse` returns messages as [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events), which + can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) +* [Raw stream](#subscribe-as-raw-stream): `/raw` returns messages as raw text, with one line per message + +### Subscribe as JSON stream +Here are a few examples of how to consume the JSON endpoint (`/json`). For almost all languages, **this is the +recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the +[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with. + +=== "Command line (curl)" + ``` + $ curl -s ntfy.sh/disk-alerts/json + {"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"} + {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Disk full"} + {"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"} + ... + ``` + +=== "ntfy CLI" + ``` + $ ntfy subcribe disk-alerts + {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Disk full"} + ... + ``` + +=== "HTTP" + ``` http + GET /disk-alerts/json HTTP/1.1 + Host: ntfy.sh + + HTTP/1.1 200 OK + Content-Type: application/x-ndjson; charset=utf-8 + Transfer-Encoding: chunked + + {"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"} + {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Disk full"} + {"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"} + ... + ``` + +=== "Go" + ``` go + resp, err := http.Get("https://ntfy.sh/disk-alerts/json") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + println(scanner.Text()) + } + ``` + +=== "Python" + ``` python + resp = requests.get("https://ntfy.sh/disk-alerts/json", stream=True) + for line in resp.iter_lines(): + if line: + print(line) + ``` + +=== "PHP" + ``` php-inline + $fp = fopen('https://ntfy.sh/disk-alerts/json', 'r'); + if (!$fp) die('cannot open stream'); + while (!feof($fp)) { + echo fgets($fp, 2048); + flush(); + } + fclose($fp); + ``` + +### Subscribe as SSE stream +Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume +notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly +easy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource). + +=== "Command line (curl)" + ``` + $ curl -s ntfy.sh/mytopic/sse + event: open + data: {"id":"weSj9RtNkj","time":1635528898,"event":"open","topic":"mytopic"} + + data: {"id":"p0M5y6gcCY","time":1635528909,"event":"message","topic":"mytopic","message":"Hi!"} + + event: keepalive + data: {"id":"VNxNIg5fpt","time":1635528928,"event":"keepalive","topic":"test"} + ... + ``` + +=== "HTTP" + ``` http + GET /mytopic/sse HTTP/1.1 + Host: ntfy.sh + + HTTP/1.1 200 OK + Content-Type: text/event-stream; charset=utf-8 + Transfer-Encoding: chunked + + event: open + data: {"id":"weSj9RtNkj","time":1635528898,"event":"open","topic":"mytopic"} + + data: {"id":"p0M5y6gcCY","time":1635528909,"event":"message","topic":"mytopic","message":"Hi!"} + + event: keepalive + data: {"id":"VNxNIg5fpt","time":1635528928,"event":"keepalive","topic":"test"} + ... + ``` + +=== "JavaScript" + ``` javascript + const eventSource = new EventSource('https://ntfy.sh/mytopic/sse'); + eventSource.onmessage = (e) => { + console.log(e.data); + }; + ``` + +### Subscribe as raw stream +The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely +simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority), +[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output +format. Keepalive messages are sent as empty lines. + +=== "Command line (curl)" + ``` + $ curl -s ntfy.sh/disk-alerts/raw + + Disk full + ... + ``` + +=== "HTTP" + ``` http + GET /disk-alerts/raw HTTP/1.1 + Host: ntfy.sh + + HTTP/1.1 200 OK + Content-Type: text/plain; charset=utf-8 + Transfer-Encoding: chunked + + Disk full + ... + ``` + +=== "Go" + ``` go + resp, err := http.Get("https://ntfy.sh/disk-alerts/raw") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + println(scanner.Text()) + } + ``` + +=== "Python" + ``` python + resp = requests.get("https://ntfy.sh/disk-alerts/raw", stream=True) + for line in resp.iter_lines(): + if line: + print(line) + ``` + +=== "PHP" + ``` php-inline + $fp = fopen('https://ntfy.sh/disk-alerts/raw', 'r'); + if (!$fp) die('cannot open stream'); + while (!feof($fp)) { + echo fgets($fp, 2048); + flush(); + } + fclose($fp); + ``` + +## WebSockets +You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely +supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line, +I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically +for WebSockets. + +The WebSockets endpoint is available at `/ws` and returns messages as JSON objects similar to the +[JSON stream endpoint](#subscribe-as-json-stream). + +=== "Command line (websocat)" + ``` + $ websocat wss://ntfy.sh/mytopic/ws + {"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"} + {"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"} + ``` + +=== "HTTP" + ``` http + GET /disk-alerts/ws HTTP/1.1 + Host: ntfy.sh + Upgrade: websocket + Connection: Upgrade + + HTTP/1.1 101 Switching Protocols + Upgrade: websocket + Connection: Upgrade + ... + ``` + +=== "Go" + ``` go + import "github.com/gorilla/websocket" + ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil) + messageType, data, err := ws.ReadMessage() + ... + ``` + +=== "JavaScript" + ``` javascript + const socket = new WebSocket('wss://ntfy.sh/mytopic/ws'); + socket.addEventListener('message', function (event) { + console.log(event.data); + }); + ``` + +## Advanced features + +### Poll for messages +You can also just poll for messages if you don't like the long-standing connection using the `poll=1` +query parameter. The connection will end after all available messages have been read. This parameter can be +combined with `since=` (defaults to `since=all`). + +``` +curl -s "ntfy.sh/mytopic/json?poll=1" +``` + +### Fetch cached messages +Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network +interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using +the `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`), +a message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages). + +``` +curl -s "ntfy.sh/mytopic/json?since=10m" +curl -s "ntfy.sh/mytopic/json?since=1645970742" +curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe" +``` + +### Fetch scheduled messages +Messages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically +returned when subscribing via the API, which makes sense, because after all, the messages have technically not been +delivered yet. To also return scheduled messages from the API, you can use the `scheduled=1` (alias: `sched=1`) +parameter (makes most sense with the `poll=1` parameter): + +``` +curl -s "ntfy.sh/mytopic/json?poll=1&sched=1" +``` + +### Filter messages +You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and +`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags +"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND. + +``` +$ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error" +{"id":"0TIkJpBcxR","time":1640122627,"event":"open","topic":"alerts"} +{"id":"X3Uzz9O1sM","time":1640122674,"event":"message","topic":"alerts","priority":4, + "tags":["error", "zfs-error"], "message":"ZFS pool corruption detected"} +``` + +Available filters (all case-insensitive): + +| Filter variable | Alias | Example | Description | +|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------| +| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID | +| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string | +| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string | +| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | +| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | + +### Subscribe to multiple topics +It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics +in the URL. This allows you to reduce the number of connections you have to maintain: + +``` +$ curl -s ntfy.sh/mytopic1,mytopic2/json +{"id":"0OkXIryH3H","time":1637182619,"event":"open","topic":"mytopic1,mytopic2,mytopic3"} +{"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"} +{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} +``` + +### Authentication +Depending on whether the server is configured to support [access control](../config.md#access-control), some topics +may be read/write protected so that only users with the correct credentials can subscribe or publish to them. +To publish/subscribe to protected topics, you can: + +* Use [basic auth](../publish.md#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk` +* or use the [`auth` query parameter](../publish.md#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw` + +Please refer to the [publishing documentation](../publish.md#authentication) for additional details. + +## JSON message format +Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON +format of the message. It's very straight forward: + +**Message**: + +| Field | Required | Type | Example | Description | +|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | +| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | +| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | +| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | +| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | +| `message` | - | *string* | `Some message` | Message body; always present in `message` events | +| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | +| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | +| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | +| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | + +**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): + +| Field | Required | Type | Example | Description | +|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------| +| `name` | ✔️ | *string* | `attachment.jpg` | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) | +| `url` | ✔️ | *URL* | `https://example.com/file.jpg` | URL of the attachment | +| `type` | -️ | *mime type* | `image/jpeg` | Mime type of the attachment, only defined if attachment was uploaded to ntfy server | +| `size` | -️ | *number* | `33848` | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server | +| `expires` | -️ | *number* | `1635528741` | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server | + +Here's an example for each message type: + +=== "Notification message" + ``` json + { + "id": "sPs71M8A2T", + "time": 1643935928, + "expires": 1643936928, + "event": "message", + "topic": "mytopic", + "priority": 5, + "tags": [ + "warning", + "skull" + ], + "click": "https://homecam.mynet.lan/incident/1234", + "attachment": { + "name": "camera.jpg", + "type": "image/png", + "size": 33848, + "expires": 1643946728, + "url": "https://ntfy.sh/file/sPs71M8A2T.png" + }, + "title": "Unauthorized access detected", + "message": "Movement detected in the yard. You better go check" + } + ``` + + +=== "Notification message (minimal)" + ``` json + { + "id": "wze9zgqK41", + "time": 1638542110, + "expires": 1638543112, + "event": "message", + "topic": "phil_alerts", + "message": "Remote access to phils-laptop detected. Act right away." + } + ``` + +=== "Open message" + ``` json + { + "id": "2pgIAaGrQ8", + "time": 1638542215, + "event": "open", + "topic": "phil_alerts" + } + ``` + +=== "Keepalive message" + ``` json + { + "id": "371sevb0pD", + "time": 1638542275, + "event": "keepalive", + "topic": "phil_alerts" + } + ``` + +=== "Poll request message" + ``` json + { + "id": "371sevb0pD", + "time": 1638542275, + "event": "poll_request", + "topic": "phil_alerts" + } + ``` + +## List of all parameters +The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**, +and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form. + +| Parameter | Aliases (case-insensitive) | Description | +|-------------|----------------------------|---------------------------------------------------------------------------------| +| `poll` | `X-Poll`, `po` | Return cached messages and close connection | +| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID | +| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list | +| `id` | `X-ID` | Filter: Only return messages that match this exact message ID | +| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string | +| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string | +| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) | +| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) | diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md new file mode 100644 index 00000000..7f589d3c --- /dev/null +++ b/docs/subscribe/cli.md @@ -0,0 +1,325 @@ +# Subscribe via ntfy CLI +In addition to subscribing via the [web UI](web.md), the [phone app](phone.md), or the [API](api.md), you can subscribe +to topics via the ntfy CLI. The CLI is included in the same `ntfy` binary that can be used to [self-host a server](../install.md). + +!!! info + The **ntfy CLI is not required to send or receive messages**. You can instead [send messages with curl](../publish.md), + and even use it to [subscribe to topics](api.md). It may be a little more convenient to use the ntfy CLI than writing + your own script. It all depends on the use case. 😀 + +## Install + configure +To install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and +client are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client +by creating `~/.config/ntfy/client.yml` (for the non-root user), `~/Library/Application Support/ntfy/client.yml` (for the macOS non-root user), or `/etc/ntfy/client.yml` (for the root user). You +can find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub. + +If you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**, +you may want to edit the `default-host` option: + +``` yaml +# Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands. +# If you self-host a ntfy server, you'll likely want to change this. +# +default-host: https://ntfy.myhost.com +``` + +## Publish messages +You can send messages with the ntfy CLI using the `ntfy publish` command (or any of its aliases `pub`, `send` or +`trigger`). There are a lot of examples on the page about [publishing messages](../publish.md), but here are a few +quick ones: + +=== "Simple send" + ``` + ntfy publish mytopic This is a message + ntfy publish mytopic "This is a message" + ntfy pub mytopic "This is a message" + ``` + +=== "Send with title, priority, and tags" + ``` + ntfy publish \ + --title="Thing sold on eBay" \ + --priority=high \ + --tags=partying_face \ + mytopic \ + "Somebody just bought the thing that you sell" + ``` + +=== "Send at 8:30am" + ``` + ntfy pub --at=8:30am delayed_topic Laterzz + ``` + +=== "Triggering a webhook" + ``` + ntfy trigger mywebhook + ntfy pub mywebhook + ``` + +### Attaching a local file +You can easily upload and attach a local file to a notification: + +``` +$ ntfy pub --file README.md mytopic | jq . +{ + "id": "meIlClVLABJQ", + "time": 1655825460, + "event": "message", + "topic": "mytopic", + "message": "You received a file: README.md", + "attachment": { + "name": "README.md", + "type": "text/plain; charset=utf-8", + "size": 2892, + "expires": 1655836260, + "url": "https://ntfy.sh/file/meIlClVLABJQ.txt" + } +} +``` + +### Wait for PID/command +If you have a long-running command and want to **publish a notification when the command completes**, +you may wrap it with `ntfy publish --wait-cmd` (aliases: `--cmd`, `--done`). Or, if you forgot to wrap it, and the +command is already running, you can wait for the process to complete with `ntfy publish --wait-pid` (alias: `--pid`). + +Run a command and wait for it to complete (here: `rsync ...`): + +``` +$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq . +{ + "id": "Re0rWXZQM8WB", + "time": 1655825624, + "event": "message", + "topic": "mytopic", + "message": "Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/" +} +``` + +Or, if you already started the long-running process and want to wait for it using its process ID (PID), you can do this: + +=== "Using a PID directly" + ``` + $ ntfy pub --wait-pid 8458 mytopic | jq . + { + "id": "orM6hJKNYkWb", + "time": 1655825827, + "event": "message", + "topic": "mytopic", + "message": "Process with PID 8458 exited after 2.003s" + } + ``` + +=== "Using a `pidof`" + ``` + $ ntfy pub --wait-pid $(pidof rsync) mytopic | jq . + { + "id": "orM6hJKNYkWb", + "time": 1655825827, + "event": "message", + "topic": "mytopic", + "message": "Process with PID 8458 exited after 2.003s" + } + ``` + +## Subscribe to topics +You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command +will either print or execute a command for every arriving message. There are a few different ways +in which the command can be run: + +### Stream messages as JSON +``` +ntfy subscribe TOPIC +``` +If you run the command like this, it prints the JSON representation of every incoming message. This is useful +when you have a command that wants to stream-read incoming JSON messages. Unless `--poll` is passed, this command +stays open forever. + +``` +$ ntfy sub mytopic +{"id":"nZ8PjH5oox","time":1639971913,"event":"message","topic":"mytopic","message":"hi there"} +{"id":"sekSLWTujn","time":1639972063,"event":"message","topic":"mytopic",priority:5,"message":"Oh no!"} +... +``` + +
+ +
Subscribe in JSON mode
+
+ +### Run command for every message +``` +ntfy subscribe TOPIC COMMAND +``` +If you run it like this, a COMMAND is executed for every incoming messages. Scroll down to see a list of available +environment variables. Here are a few examples: + +``` +ntfy sub mytopic 'notify-send "$m"' +ntfy sub topic1 /my/script.sh +ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p' +``` + +
+ +
Execute command on incoming messages
+
+ +The message fields are passed to the command as environment variables and can be used in scripts. Note that since +these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them +in double-quotes, you should be fine: + +| Variable | Aliases | Description | +|------------------|----------------------------|----------------------------------------| +| `$NTFY_ID` | `$id` | Unique message ID | +| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery | +| `$NTFY_TOPIC` | `$topic` | Topic name | +| `$NTFY_MESSAGE` | `$message`, `$m` | Message body | +| `$NTFY_TITLE` | `$title`, `$t` | Message title | +| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) | +| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) | +| `$NTFY_RAW` | `$raw` | Raw JSON message | + +### Subscribe to multiple topics +``` +ntfy subscribe --from-config +``` +To subscribe to multiple topics at once, and run different commands for each one, you can use `ntfy subscribe --from-config`, +which will read the `subscribe` config from the config file. Please also check out the [ntfy-client systemd service](#using-the-systemd-service). + +Here's an example config file that subscribes to three different topics, executing a different command for each of them: + +=== "~/.config/ntfy/client.yml (Linux)" + ```yaml + subscribe: + - topic: echo-this + command: 'echo "Message received: $message"' + - topic: alerts + command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m" + if: + priority: high,urgent + - topic: calc + command: 'gnome-calculator 2>/dev/null &' + - topic: print-temp + command: | + echo "You can easily run inline scripts, too." + temp="$(sensors | awk '/Pack/ { print substr($4,2,2) }')" + if [ $temp -gt 80 ]; then + echo "Warning: CPU temperature is $temp. Too high." + else + echo "CPU temperature is $temp. That's alright." + fi + ``` + + +=== "~/Library/Application Support/ntfy/client.yml (macOS)" + ```yaml + subscribe: + - topic: echo-this + command: 'echo "Message received: $message"' + - topic: alerts + command: osascript -e "display notification \"$message\"" + if: + priority: high,urgent + - topic: calc + command: open -a Calculator + ``` + +=== "%AppData%\ntfy\client.yml (Windows)" + ```yaml + subscribe: + - topic: echo-this + command: 'echo Message received: %message%' + - topic: alerts + command: | + notifu /m "%NTFY_MESSAGE%" + exit 0 + if: + priority: high,urgent + - topic: calc + command: calc + ``` + +In this example, when `ntfy subscribe --from-config` is executed: + +* Messages to `echo-this` simply echos to standard out +* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux), + [notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS) +* Messages to `calc` open the calculator 😀 (*because, why not*) +* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only) + +I hope this shows how powerful this command is. Here's a short video that demonstrates the above example: + +
+ +
Execute all the things
+
+ +If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both). +You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults +will be used, otherwise, the subscription settings will override the defaults. + +!!! warning + Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not + require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password. + +### Using the systemd service +You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service)) +to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started) +if you install the deb/rpm package. To configure it, simply edit `/etc/ntfy/client.yml` and run `sudo systemctl restart ntfy-client`. + +!!! info + The `ntfy-client.service` runs as user `ntfy`, meaning that typical Linux permission restrictions apply. See below + for how to fix this. + +If the service runs on your personal desktop machine, you may want to override the service user/group (`User=` and `Group=`), and +adjust the `DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` environment variables. This will allow you to run commands in your X session +as the primary machine user. + +You can either manually override these systemd service entries with `sudo systemctl edit ntfy-client`, and add this +(assuming your user is `phil`). Don't forget to run `sudo systemctl daemon-reload` and `sudo systemctl restart ntfy-client` +after editing the service file: + +=== "/etc/systemd/system/ntfy-client.service.d/override.conf" + ``` + [Service] + User=phil + Group=phil + Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" + ``` +Or you can run the following script that creates this override config for you: + +``` +sudo sh -c 'cat > /etc/systemd/system/ntfy-client.service.d/override.conf' < + + + +You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and +from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that +the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). + +Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app. +The PWA is a website that you can add to your home screen, and it will behave just like a native app. + +## Overview +A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty +straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them. + +
+ + + + + + +
+ +If those screenshots are still not enough, here's a video: + +
+ +
Sending push notifications to your Android phone
+
+ +## Message priority +_Supported on:_ :material-android: :material-apple: + +When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines +how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate. + +By default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent +priority (>= 4) will also show as pop-over, like so: + +
+ ![priority notification](../static/img/priority-notification.png){ width=500 } +
High and urgent notifications show as pop-over
+
+ +You can change these settings in Android by long-pressing on the app, and tapping "Notifications", or from the "Settings" +menu under "Channel settings". There is one notification channel for each priority: + +
+ ![notification settings](../static/img/android-screenshot-notification-settings.png){ width=500 } +
Per-priority channels
+
+ +Per notification channel, you can configure a **channel-specific sound**, whether to **override the Do Not Disturb (DND)** +setting, and other settings such as popover or notification dot: + +
+ ![channel details](../static/img/android-screenshot-notification-details.jpg){ width=500 } +
Per-priority sound/vibration settings
+
+ +## Instant delivery +_Supported on:_ :material-android: + +Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e. +when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which +you'll see as a permanent notification that looks like this: + +
+ ![foreground service](../static/img/foreground-service.png){ width=500 } +
Instant delivery foreground notification
+
+ +Android does not allow you to dismiss this notification, unless you turn off the notification channel in the settings. +To do so, long-press on the foreground notification (screenshot above) and navigate to the settings. Then toggle the +"Subscription Service" off: + +
+ ![foreground service](../static/img/notification-settings.png){ width=500 } +
Turning off the persistent instant delivery notification
+
+ +**Limitations without instant delivery**: Without instant delivery, **messages may arrive with a significant delay** +(sometimes many minutes, or even hours later). If you've ever picked up your phone and +suddenly had 10 messages that were sent long before you know what I'm talking about. + +The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging). FCM is the +*only* Google approved way to send push messages to Android devices, and it's what pretty much all apps use to deliver push +notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it. + +The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app. +It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor. + +## Share to topic +_Supported on:_ :material-android: + +You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files +or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics +you shared content to and lists them at the bottom. + +The feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures: + +
+ + +
+ +## ntfy:// links +_Supported on:_ :material-android: + +The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps) +such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), +or to simply directly link to a topic from a mobile website. + +!!! info + Android deep linking of http/https links is very brittle and limited, which is why something like `https:////subscribe` is + **not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20). + +**Supported link formats:** + +| Link format | Example | Description | +|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ntfy:///` | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) | +| `ntfy:///?secure=false` | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) | + +## Integrations + +### UnifiedPush +_Supported on:_ :material-android: + +[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned +[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications +in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it. + +To use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/). +That's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md) +to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/): + +
+ + + +
+ +### Automation apps +_Supported on:_ :material-android: + +The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) +or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can +**react to incoming messages**, as well as **send messages**. + +#### React to incoming messages +To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see +[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)). +Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) +and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch +broadcasts is supported: + +
+ + + + + + + +
+ +For MacroDroid, be sure to type in the package name `io.heckel.ntfy`, otherwise intents may be silently swallowed. +If you're using topics to drive automation, you'll likely want to mute the topic in the ntfy app. This will prevent +notification popups: + +
+ ![muted subscription](../static/img/android-screenshot-muted.png){ width=500 } +
Muting notifications to prevent popups
+
+ +Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`: + +| Extra name | Type | Example | Description | +|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------| +| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) | +| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from | +| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** | +| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app | +| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` | +| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp | +| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set | +| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** | +| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data | +| `encoding`️ | *String* | - | Message encoding (empty or "base64") | +| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) | +| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag | +| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | *String* | `https://google.com` | [Click action](../publish.md#click-action) URL, or empty if not set | +| `attachment_name` | *String* | `attachment.jpg` | Filename of the attachment; may be empty if not set | +| `attachment_type` | *String* | `image/jpeg` | Mime type of the attachment; may be empty if not set | +| `attachment_size` | *Long* | `9923111` | Size in bytes of the attachment; may be zero if not set | +| `attachment_expires` | *Long* | `1655514244` | Expiry date as Unix timestamp of the attachment URL; may be zero if not set | +| `attachment_url` | *String* | `https://ntfy.sh/file/afUbjadfl7ErP.jpg` | URL of the attachment; may be empty if not set | + +#### Send messages using intents +To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) +and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can +broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP +POST request to [publish a message](../publish.md). This is primarily useful for apps that do not support HTTP POST/PUT +(like MacroDroid). In Tasker, you can simply use the "HTTP Request" action, which is a little easier and also works if +ntfy is not installed. + +Here's what that looks like: + +
+ + + + + +
+ +The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action: + +| Extra name | Required | Type | Example | Description | +|--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------| +| `base_url` | - | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` | +| `topic` ❤️ | ✔ | *String* | `mytopic` | Topic name; **you must set this** | +| `title` | - | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set | +| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** | +| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) | +| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | diff --git a/docs/subscribe/pwa.md b/docs/subscribe/pwa.md new file mode 100644 index 00000000..5dcaa257 --- /dev/null +++ b/docs/subscribe/pwa.md @@ -0,0 +1,69 @@ +# Using the progressive web app (PWA) +While ntfy doesn't have a native desktop app, it is built as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) (PWA) +and thus can be **installed on both desktop and mobile devices**. + +This gives it its own launcher (e.g. shortcut on Windows, app on macOS, launcher shortcut on Linux, home screen icon on iOS, and +launcher icon on Android), a standalone window, push notifications, and an app badge with the unread notification count. + +Web app installation is **supported on** (see [compatibility table](https://caniuse.com/web-app-manifest) for details): + +- **Chrome:** Android, Windows, Linux, macOS +- **Safari:** iOS 16.4+, macOS 14+ +- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/) +- **Edge:** Windows + +Note that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work. + +## Installation + +### Chrome on Desktop +To install and register the web app via Chrome, click the "install app" icon. After installation, you can find the app in your +app drawer: + +
+ + + +
+ +### Safari on macOS +To install and register the web app via Safari, click on the Share menu and click Add to Dock. You need to be on macOS Sonoma (14) or higher. + +
+ +
+ +### Chrome/Firefox on Android +For Chrome on Android, either click the "Add to Home Screen" banner at the bottom of the screen, or select "Install app" +in the menu, and then click "Install" in the popup menu. After installation, you can find the app in your app drawer, +and on your home screen. + +
+ + + +
+ +For Firefox, select "Install" in the menu, and then click "Add" to add an icon to your home screen: + +
+ + +
+ +### Safari on iOS +On iOS Safari, tap on the Share menu, then tap "Add to Home Screen": + +
+ + + +
+ +## Background notifications +Background notifications via web push are enabled by default and cannot be turned off when the app is installed, as notifications would +not be delivered reliably otherwise. You can mute topics you don't want to receive notifications for. + +On desktop, you generally need either your browser or the web app open to receive notifications, though the ntfy tab doesn't need to be +open. On mobile, you don't need to have the web app open to receive notifications. Look at the [web docs](./web.md#background-notifications) +for a detailed breakdown. diff --git a/docs/subscribe/web.md b/docs/subscribe/web.md new file mode 100644 index 00000000..859f7d0a --- /dev/null +++ b/docs/subscribe/web.md @@ -0,0 +1,75 @@ +# Subscribe from the web app +The web app lets you subscribe and publish messages to ntfy topics. For ntfy.sh, the web app is available at [ntfy.sh/app](https://ntfy.sh/app). +To subscribe, simply type in the topic name and click the *Subscribe* button. **After subscribing, messages published to the topic +will appear in the web app, and pop up as a notification.** + +
+ +
+ +## Publish messages +To learn how to send messages, check out the [publishing page](../publish.md). + +
+ + +
+ +## Topic reservations +If topic reservations are enabled, you can claim ownership over topics and define access to it: + +
+ + +
+ +## Notification features and browser support + +- Emoji tags are supported in all browsers + +- [Click](../publish.md#click-action) actions are supported in all browsers + +- Only Chrome, Edge, and Opera support displaying view and http [actions](../publish.md#action-buttons) in notifications. + + Their presentation is platform specific. + + Note that HTTP actions are performed using fetch and thus are limited to the [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) + rules, which means that any URL you include needs to respond to a [preflight request](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) + with headers allowing the origin of the ntfy web app (`Access-Control-Allow-Origin: https://ntfy.sh`) or `*`. + +- Only Chrome, Edge, and Opera support displaying [images](../publish.md#attachments) in notifications. + +Look at the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility) +for more info. + +## Background notifications +While subscribing, you have the option to enable background notifications on supported browsers (see "Settings" tab). + +Note: If you add the web app to your homescreen (as a progressive web app, more info in the [installed web app](pwa.md) +docs), you cannot turn these off, as notifications would not be delivered reliably otherwise. You can mute topics you don't want to receive +notifications for. + +**If background notifications are off:** This requires an active ntfy tab to be open to receive notifications. +These are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser +is allowed to show notifications (for example in System Settings on macOS). If you don't want to enable background notifications, +**pinning the ntfy tab on your browser** is a good solution to leave it running. + +**If background notifications are on:** This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active +ntfy tab open, but in some cases you may need to keep your browser open. Background notifications are only supported on the +same server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself. + +If the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them +by opening the app again, and will get a warning notification before they are paused. + +| Browser | Platform | Browser Running | Browser Not Running | Restrictions | +|---------|----------|-----------------|---------------------|---------------------------------------------------------| +| Chrome | Desktop | ✅ | ❌ | | +| Firefox | Desktop | ✅ | ❌ | | +| Edge | Desktop | ✅ | ❌ | | +| Opera | Desktop | ✅ | ❌ | | +| Safari | Desktop | ✅ | ✅ | requires Safari 16.1, macOS 13 Ventura | +| Chrome | Android | ✅ | ✅ | | +| Firefox | Android | ✅ | ✅ | | +| Safari | iOS | ⚠️ | ⚠️ | requires iOS 16.4, only when app is added to homescreen | + +(Browsers below 1% usage not shown, look at the [Push API](https://caniuse.com/push-api) for more info) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..d37561c5 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,131 @@ +# Troubleshooting +This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list. +If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) +and ask there. We're happy to help. + +## ntfy server +If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing +in the server. You can find detailed instructions in the [Logging & Debugging](config.md#logging-debugging) section, but it ultimately +boils down to setting `log-level: debug` or `log-level: trace` in the `server.yml` file: + +=== "server.yml (debug)" + ``` yaml + log-level: debug + ``` + +=== "server.yml (trace)" + ``` yaml + log-level: trace + ``` + +If you're using environment variables, set `NTFY_LOG_LEVEL=debug` (or `trace`) instead. You can also pass `--debug` or `--trace` +to the `ntfy serve` command, e.g. `ntfy serve --trace`. If you're using systemd (i.e. `systemctl`) to run ntfy, you can look at +the logs using `journalctl -u ntfy -f`. The logs will look something like this: + +=== "Example logs (debug)" + ``` + $ ntfy serve --debug + 2023/03/20 14:45:38 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is DEBUG (tag=startup) + 2023/03/20 14:45:38 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter) + 2023/03/20 14:45:39 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG HTTP request started (http_method=POST, http_path=/mytopic, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG Received message (http_method=POST, http_path=/mytopic, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:45:38.319-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002132248, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000259165, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=2, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0004147334, visitor_seen=2023-03-20T14:45:39.7-04:00) + 2023/03/20 14:45:39 DEBUG Wrote 1 message(s) in 8.285712ms (tag=message_cache) + ... + ``` + +=== "Example logs (trace)" + ``` + $ ntfy serve --trace + 2023/03/20 14:40:42 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is TRACE (tag=startup) + 2023/03/20 14:40:42 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter) + 2023/03/20 14:40:59 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 TRACE HTTP request started (http_method=POST, http_path=/mytopic, http_request=POST /mytopic HTTP/1.1 + User-Agent: curl/7.81.0 + Accept: */* + Content-Length: 2 + Content-Type: application/x-www-form-urlencoded + + hi, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 TRACE Received message (http_method=POST, http_path=/mytopic, message_body={ + "id": "Khaup1RVclU3", + "time": 1679337659, + "expires": 1679380859, + "event": "message", + "topic": "mytopic", + "message": "hi" + }, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:40:59.893-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0001785048, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002044368, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=1, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000220502, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:40:59 TRACE No stream or WebSocket subscribers, not forwarding (message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002369212, visitor_seen=2023-03-20T14:40:59.893-04:00) + 2023/03/20 14:41:00 DEBUG Wrote 1 message(s) in 9.529196ms (tag=message_cache) + ... + ``` + +## Android app +On Android, you can turn on logging in the settings under **Settings → Record logs**. This will store up to 1,000 log +entries, which you can then copy or upload. + +
+ ![Recording logs on Android](static/img/android-screenshot-logs.jpg){ width=400 } +
Recording logs on Android
+
+ +When you copy or upload the logs, you can censor them to make it easier to share them with others. ntfy will replace all +topics and hostnames with fruits. Here's an example: + +``` +This is a log of the ntfy Android app. The log shows up to 1,000 entries. +Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑. + +Device info: +-- +ntfy: 1.16.0 (play) +OS: 4.19.157-perf+ +Android: 13 (SDK 33) +... + +Logs +-- + +1679339199507 2023-03-20 15:06:39.507 D NtfyMainActivity Battery: ignoring optimizations = true (we want this to be true); instant subscriptions = true; remind time reached = true; banner = false +1679339199507 2023-03-20 15:06:39.507 D NtfySubscriberMgr Enqueuing work to refresh subscriber service +1679339199589 2023-03-20 15:06:39.589 D NtfySubscriberMgr ServiceStartWorker: Starting foreground service with action START (work ID: a7eeeae9-9356-40df-afbd-236e5ed10a0b) +1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService onStartCommand executed with startId: 262 +1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService using an intent with action START +1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService Refreshing subscriptions +1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Desired connections: [ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869}), ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328})] +1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Active connections: [ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328}), ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869})] +... +``` + +To get live logs, or to get more advanced access to an Android phone, you can use [adb](https://developer.android.com/studio/command-line/adb). +After you install and [enable adb debugging](https://developer.android.com/studio/command-line/adb#Enabling), you can +get detailed logs like so: + +``` +# Connect to phone (enable Wireless debugging first) +adb connect 192.168.1.137:39539 + +# Print all logs; you may have to pass the -s option +adb logcat +adb -s 192.168.1.137:39539 logcat + +# Only list ntfy logs +adb logcat --pid=$(adb shell pidof -s io.heckel.ntfy) +adb -s 192.168.1.137:39539 logcat --pid=$(adb -s 192.168.1.137:39539 shell pidof -s io.heckel.ntfy) +``` + +## Web app +The web app logs everything to the **developer console**, which you can open by **pressing the F12 key** on your +keyboard. + +
+ ![Web app logs](static/img/web-logs.png) +
Web app logs in the developer console
+
+ +## iOS app +Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode. diff --git a/examples/grafana-dashboard/ntfy-grafana.json b/examples/grafana-dashboard/ntfy-grafana.json new file mode 100644 index 00000000..11273da3 --- /dev/null +++ b/examples/grafana-dashboard/ntfy-grafana.json @@ -0,0 +1,2400 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "9.4.3" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 38, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "light-green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 0, + "y": 1 + }, + "id": 36, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "ntfy_messages_published_success{job=\"$job\"}", + "legendFormat": "Messages cached", + "range": true, + "refId": "A" + } + ], + "title": "Published", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 4, + "y": 1 + }, + "id": 33, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_cached_total{job=\"$job\"}", + "legendFormat": "Messages cached", + "range": true, + "refId": "A" + } + ], + "title": "Cached", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#69bfb5", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 8, + "y": 1 + }, + "id": 31, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_visitors_total{job=\"$job\"}", + "legendFormat": "Visitors", + "range": true, + "refId": "A" + } + ], + "title": "Visitors", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 12, + "y": 1 + }, + "id": 32, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_users_total{job=\"$job\"}", + "legendFormat": "Visitors", + "range": true, + "refId": "A" + } + ], + "title": "Users", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 16, + "y": 1 + }, + "id": 34, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_topics_total{job=\"$job\"}", + "legendFormat": "Topics", + "range": true, + "refId": "A" + } + ], + "title": "Topics", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 1 + }, + "id": 35, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_subscribers_total", + "legendFormat": "Subscribers", + "range": true, + "refId": "A" + } + ], + "title": "Subscribers", + "type": "stat" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 10, + "title": "Metrics", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Number of successfully published messages, and messages that could not be published (due to rate limiting, bad formatting, etc.)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "custom.axisColorMode", + "value": "text" + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 42, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_messages_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_messages_published_failure{job=\"$job\"}[$rate])", + "hide": false, + "legendFormat": "Failed", + "range": true, + "refId": "B" + } + ], + "title": "Messages published (per second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Number of messages published since last ntfy server restart", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_published_success{job=\"$job\"}", + "legendFormat": "Successful", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_published_failure{job=\"$job\"}", + "hide": false, + "legendFormat": "Failed", + "range": true, + "refId": "B" + } + ], + "title": "Messages published", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Number of messages currently stored in message cache", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_messages_cached_total{job=\"$job\"}", + "legendFormat": "Messages in database", + "range": true, + "refId": "A" + } + ], + "title": "Messages cached", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_visitors_total{job=\"$job\"}", + "legendFormat": "Visitors", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_topics_total{job=\"$job\"}", + "hide": false, + "legendFormat": "Topics", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_subscribers_total{job=\"$job\"}", + "hide": false, + "legendFormat": "Subscribers", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_users_total{job=\"$job\"}", + "hide": false, + "legendFormat": "Users", + "range": true, + "refId": "D" + } + ], + "title": "Visitors, subscribers, topics", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 12 + }, + "id": 43, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum by(job) (rate(ntfy_http_requests_total{job=\"$job\"}[$rate]))", + "legendFormat": "Requests per second", + "range": true, + "refId": "A" + } + ], + "title": "HTTP requests (per second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 6, + "y": 12 + }, + "id": 41, + "options": { + "legend": { + "calcs": [ + "mean" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum by(http_code) (rate(ntfy_http_requests_total{job=\"$job\", http_code!=\"200\", http_code!=\"429\", http_code!=\"507\"}[$rate]))", + "legendFormat": "{{http_code}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP errors (per second, excl. 429/507)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 15, + "y": 12 + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "mean" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum by(ntfy_code) (rate(ntfy_http_requests_total{http_code!=\"200\", job=\"$job\"}[$rate]))", + "legendFormat": "{{http_method}} {{http_code}} {{ntfy_code}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP errors (per second, ntfy code)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 19 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_attachments_total_size{job=\"$job\"}", + "legendFormat": "Total size in MB", + "range": true, + "refId": "A" + } + ], + "title": "Attachments: Total cache size", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": -1, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 19 + }, + "id": 27, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_firebase_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_firebase_published_failure{job=\"$job\"}[$rate])", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Firebase messages sent", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Rejected (HTTP 507)" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 19 + }, + "id": 26, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_unifiedpush_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_http_requests_total{job=\"$job\",http_code=\"507\"}[$rate])", + "hide": false, + "legendFormat": "Rejected (HTTP 507)", + "range": true, + "refId": "B" + } + ], + "title": "UnifiedPush messages", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 24, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_matrix_published_success{job=\"$job\"}[$rate])", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(ntfy_matrix_published_failure{job=\"$job\"}[$rate])", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Matrix messages published", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 26 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_sent_success{job=\"$job\"}", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_sent_failure{job=\"$job\"}", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Emails sent", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 26 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_received_success{job=\"$job\"}", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_emails_received_failure{job=\"$job\"}", + "hide": false, + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Emails received", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 26 + }, + "id": 29, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "ntfy_message_publish_duration_ms{job=\"$job\"}", + "legendFormat": "Duration", + "range": true, + "refId": "A" + } + ], + "title": "Message publish duration", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 8, + "panels": [], + "title": "Internals", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 34 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "go_goroutines{job=\"$job\"}", + "legendFormat": "Go routines", + "range": true, + "refId": "A" + } + ], + "title": "Go routines", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "log": 10, + "type": "symlog" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 34 + }, + "id": 44, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_open_fds{job=\"$job\"}", + "legendFormat": "Open", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_max_fds{job=\"$job\"}", + "hide": false, + "legendFormat": "Max", + "range": true, + "refId": "B" + } + ], + "title": "File descriptors", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 34 + }, + "id": 45, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_resident_memory_bytes{job=\"$job\"}", + "legendFormat": "Resident memory used by ntfy (RSS)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "builder", + "expr": "process_virtual_memory_bytes{job=\"$job\"}", + "hide": false, + "legendFormat": "Virtual memory used by ntfy (VSS)", + "range": true, + "refId": "B" + } + ], + "title": "Resident/virtual memory", + "type": "timeseries" + } + ], + "refresh": "10s", + "revision": 1, + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "definition": "label_values(ntfy_visitors_total, job)", + "hide": 0, + "includeAll": false, + "label": "Job", + "multi": false, + "name": "job", + "options": [], + "query": { + "query": "label_values(ntfy_visitors_total, job)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "selected": false, + "text": "30m", + "value": "30m" + }, + "description": "Average per-second rates over values from this time span", + "hide": 0, + "label": "Rate", + "name": "rate", + "options": [ + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": true, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + } + ], + "query": "1m,5m,10m,30m,1h", + "queryValue": "", + "refresh": 2, + "skipUrlSync": false, + "type": "interval" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "ntfy App", + "uid": "TO6HgexVz", + "version": 24, + "weekStart": "" +} \ No newline at end of file diff --git a/examples/example_desktop_notifications.sh b/examples/linux-desktop-notifications/notify-desktop.sh similarity index 76% rename from examples/example_desktop_notifications.sh rename to examples/linux-desktop-notifications/notify-desktop.sh index bc93128c..2e20fc42 100644 --- a/examples/example_desktop_notifications.sh +++ b/examples/linux-desktop-notifications/notify-desktop.sh @@ -2,6 +2,8 @@ # This is an example shell script showing how to consume a ntfy.sh topic using # a simple script. The notify-send command sends any arriving message as a desktop notification. +TOPIC_URL=ntfy.sh/mytopic + while read msg; do [ -n "$msg" ] && notify-send "$msg" -done < <(stdbuf -i0 -o0 curl -s ntfy.sh/mytopic/raw) +done < <(stdbuf -i0 -o0 curl -s $TOPIC_URL/raw) diff --git a/examples/publish-go/main.go b/examples/publish-go/main.go new file mode 100644 index 00000000..35fac138 --- /dev/null +++ b/examples/publish-go/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "net/http" + "strings" +) + +func main() { + // Without additional headers (priority, tags, title), it's a one liner. + // Check out https://ntfy.sh/mytopic in your browser after running this. + http.Post("https://ntfy.sh/mytopic", "text/plain", strings.NewReader("Backup successful 😀")) + + // If you'd like to add title, priority, or tags, it's a little harder. + // Check out https://ntfy.sh/phil_alerts in your browser. + req, err := http.NewRequest("POST", "https://ntfy.sh/phil_alerts", + strings.NewReader("Remote access to phils-laptop detected. Act right away.")) + if err != nil { + log.Fatal(err) + } + req.Header.Set("Title", "Unauthorized access detected") + req.Header.Set("Priority", "urgent") + req.Header.Set("Tags", "warning,skull") + if _, err := http.DefaultClient.Do(req); err != nil { + log.Fatal(err) + } +} diff --git a/examples/publish-php/publish.php b/examples/publish-php/publish.php new file mode 100644 index 00000000..c7c6eefc --- /dev/null +++ b/examples/publish-php/publish.php @@ -0,0 +1,14 @@ + [ + 'method' => 'POST', // PUT also works + 'header' => + "Content-Type: text/plain\r\n" . + "Title: Unauthorized access detected\r\n" . + "Priority: urgent\r\n" . + "Tags: warning,skull", + 'content' => 'Remote access to phils-laptop detected. Act right away.' + ] +])); diff --git a/examples/publish-python/publish.py b/examples/publish-python/publish.py new file mode 100755 index 00000000..79bb1fde --- /dev/null +++ b/examples/publish-python/publish.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + +import requests + +resp = requests.get("https://ntfy.sh/mytopic/trigger", + data="Backup successful 😀".encode(encoding='utf-8'), + headers={ + "Priority": "high", + "Tags": "warning,skull", + "Title": "Hello there" + }) +resp.raise_for_status() diff --git a/examples/ssh-login-alert/ntfy-ssh-login.sh b/examples/ssh-login-alert/ntfy-ssh-login.sh new file mode 100644 index 00000000..1c9c1407 --- /dev/null +++ b/examples/ssh-login-alert/ntfy-ssh-login.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# This is a PAM script hook that shows how to notify you when +# somebody logs into your server. Place at /usr/local/bin/ntfy-ssh-login.sh (with chmod +x!). + +TOPIC_URL=ntfy.sh/alerts + +if [ "${PAM_TYPE}" = "open_session" ]; then + curl -H tags:warning -H prio:high -d "SSH login to $(hostname): ${PAM_USER} from ${PAM_RHOST}" "${TOPIC_URL}" +fi diff --git a/examples/ssh-login-alert/pam_sshd b/examples/ssh-login-alert/pam_sshd new file mode 100644 index 00000000..fe1eec6a --- /dev/null +++ b/examples/ssh-login-alert/pam_sshd @@ -0,0 +1,8 @@ +# PAM config file snippet +# +# Put this snippet AT THE END of the file /etc/pam.d/sshd +# See https://geekthis.net/post/run-scripts-after-ssh-authentication/ for details. + +# (lots of stuff here ...) + +session optional pam_exec.so /usr/local/bin/ntfy-ssh-login.sh diff --git a/examples/subscribe-go/main.go b/examples/subscribe-go/main.go new file mode 100644 index 00000000..5f807610 --- /dev/null +++ b/examples/subscribe-go/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "bufio" + "log" + "net/http" +) + +func main() { + resp, err := http.Get("https://ntfy.sh/phil_alerts/json") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + println(scanner.Text()) + } +} diff --git a/examples/subscribe-php/subscribe.php b/examples/subscribe-php/subscribe.php new file mode 100644 index 00000000..fcdbe98b --- /dev/null +++ b/examples/subscribe-php/subscribe.php @@ -0,0 +1,12 @@ + ntfy.sh: EventSource Example +