forked from mirrors/ntfy
1
0
Fork 0

Compare commits

..

1 Commits

Author SHA1 Message Date
binwiederhier db1a1fec0c Custom HTTP response writer 2023-02-17 09:07:57 -05:00
242 changed files with 25000 additions and 40207 deletions

View File

@ -1,3 +0,0 @@
dist
*/node_modules
Dockerfile*

View File

@ -1,11 +0,0 @@
# 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

View File

@ -1,26 +0,0 @@
---
name: 🐛 Bug Report
about: Report any errors and problems
title: ''
labels: '🪲 bug'
assignees: ''
---
:lady_beetle: **Describe the bug**
<!-- A clear and concise description of the problem. -->
:computer: **Components impacted**
<!-- ntfy server, Android app, iOS app, web app -->
:bulb: **Screenshots and/or logs**
<!--
If applicable, add screenshots or share logs help explain your problem.
To get logs from the ...
- ntfy server: Enable "log-level: trace" in your server.yml file
- Android app: Go to "Settings" -> "Record logs", then eventually "Copy/upload logs"
- web app: Press "F12" and find the "Console" window
-->
:crystal_ball: **Additional context**
<!-- Add any other context about the problem here. -->

View File

@ -1,26 +0,0 @@
---
name: 💡 Feature/Enhancement Request
about: Got a great idea? Let us know!
title: ''
labels: 'enhancement'
assignees: ''
---
<!--
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
sooner, and there are more people there to help!
- Discord: https://discord.gg/cT7ECsZj9w
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
-->
:bulb: **Idea**
<!-- Share your thoughts; try to be detailed if you can -->
:computer: **Target components**
<!-- Where should this feature/enhancement be added? -->
<!-- e.g. ntfy server, Android app, iOS app, web app -->

View File

@ -1,21 +0,0 @@
---
name: 🆘 I need help with ...
about: Installing ntfy, configuring the app, etc.
title: ''
labels: 'tech-support'
assignees: ''
---
<!--
STOP!
This is not the right place to ask for help. Consider asking on Discord/Matrix instead.
You'll usually get an answer sooner, and there are more people there to help!
- Discord: https://discord.gg/cT7ECsZj9w
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
-->

View File

@ -1,21 +0,0 @@
---
name: ❓ Question
about: Ask a question about ntfy
title: ''
labels: 'question'
assignees: ''
---
<!--
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
sooner, and there are more people there to help!
- Discord: https://discord.gg/cT7ECsZj9w
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
-->
:question: **Question**
<!-- Go ahead and ask your question here :) -->

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@ -4,21 +4,30 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@v2
with:
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@v2
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
node-version: '17'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Install dependencies
run: make build-deps-ubuntu

View File

@ -30,7 +30,7 @@ jobs:
run: |
cd build/ntfy-docs.github.io
git config user.name "GitHub Actions Bot"
git config user.email "<actions@github.com>"
git config user.email "<>"
git add docs/
git commit -m "Updated docs"
git push origin main

View File

@ -7,21 +7,30 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@v2
with:
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@v2
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
node-version: '17'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Docker login
uses: docker/login-action@v2

View File

@ -4,21 +4,30 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@v2
with:
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@v2
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
node-version: '17'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Install dependencies
run: make build-deps-ubuntu

2
.gitignore vendored
View File

@ -1,5 +1,4 @@
dist/
dev-dist/
build/
.idea/
.vscode/
@ -12,4 +11,3 @@ secrets/
*.iml
node_modules/
.DS_Store
__pycache__

View File

@ -97,7 +97,7 @@ nfpms:
- dst: /var/lib/ntfy
type: dir
- dst: /usr/share/ntfy/logo.png
src: web/public/static/images/ntfy.png
src: web/public/static/img/ntfy.png
scripts:
preinstall: "scripts/preinst.sh"
postinstall: "scripts/postinst.sh"

View File

@ -1,15 +1,9 @@
FROM r.batts.cloud/debian:testing
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"
FROM alpine
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
COPY ntfy /usr/bin
HEALTHCHECK --interval=60s --timeout=10s CMD wget -q --tries=1 http://localhost/v1/health -O - | grep -Eo '"healthy"\s*:\s*true' || exit 1
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

View File

@ -1,54 +0,0 @@
FROM r.batts.cloud/golang:1.19 as builder
ARG VERSION=dev
ARG COMMIT=unknown
RUN apt-get update
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash
RUN apt-get install -y \
build-essential \
nodejs \
python3-pip \
python3-venv
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 r.batts.cloud/debian:testing
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"]

View File

@ -31,16 +31,10 @@ help:
@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-format - Run prettier on the web app"
@echo " make web-format-check - Run prettier on the web app, but don't change anything"
@echo
@echo "Build documentation:"
@echo " make docs - Build the documentation"
@ -86,33 +80,23 @@ 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 \
sudo apt update
sudo apt install -y \
curl \
gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \
jq
which pip3 || sudo apt-get install -y python3-pip
which pip3 || sudo apt install -y python3-pip
# Documentation
docs: docs-deps docs-build
docs-build: venv .PHONY
@. venv/bin/activate && \
if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
docs-build: .PHONY
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
if which python3.8; then \
echo "python3.8 $(shell which mkdocs) build"; \
python3.8 $(shell which mkdocs) build; \
@ -125,15 +109,10 @@ docs-build: venv .PHONY
mkdocs build; \
fi
venv:
python3 -m venv ./venv
docs-deps: venv .PHONY
. venv/bin/activate && \
docs-deps: .PHONY
pip3 install -r requirements.txt
docs-deps-update: venv .PHONY
. venv/bin/activate && \
docs-deps-update: .PHONY
pip3 install -r requirements.txt --upgrade
@ -148,7 +127,8 @@ web-build:
&& rm -rf ../server/site \
&& mv build ../server/site \
&& rm \
../server/site/config.js
../server/site/config.js \
../server/site/asset-manifest.json
web-deps:
cd web && npm install
@ -157,37 +137,29 @@ web-deps:
web-deps-update:
cd web && npm update
web-format:
cd web && npm run format
web-format-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
goreleaser build --snapshot --rm-dist
cli-linux-amd64: cli-deps-static-sites
goreleaser build --snapshot --clean --id ntfy_linux_amd64
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
goreleaser build --snapshot --clean --id ntfy_linux_armv6
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
goreleaser build --snapshot --clean --id ntfy_linux_armv7
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
goreleaser build --snapshot --clean --id ntfy_linux_arm64
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
cli-windows-amd64: cli-deps-static-sites
goreleaser build --snapshot --clean --id ntfy_windows_amd64
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
cli-darwin-all: cli-deps-static-sites
goreleaser build --snapshot --clean --id ntfy_darwin_all
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
cli-linux-server: cli-deps-static-sites
# This is a target to build the CLI (including the server) manually.
@ -254,7 +226,7 @@ cli-build-results:
# Test/check targets
check: test web-format-check fmt-check vet web-lint lint staticcheck
check: test fmt-check vet lint staticcheck
test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
@ -305,11 +277,11 @@ staticcheck: .PHONY
# Releasing targets
release: clean cli-deps release-checks docs web check
goreleaser release --clean
release: clean update cli-deps release-checks docs web check
goreleaser release --rm-dist
release-snapshot: clean cli-deps docs web check
goreleaser release --snapshot --skip-publish --clean
release-snapshot: clean update cli-deps docs web check
goreleaser release --snapshot --skip-publish --rm-dist
release-checks:
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))

View File

@ -1,4 +1,4 @@
![ntfy](web/public/static/images/ntfy.png)
![ntfy](web/public/static/img/ntfy.png)
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest)
@ -13,26 +13,20 @@
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
**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.
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android)
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).
<p>
<img src=".github/images/screenshot-curl.png" height="180">
<img src=".github/images/screenshot-web-detail.png" height="180">
<img src=".github/images/screenshot-phone-main.jpg" height="180">
<img src=".github/images/screenshot-phone-detail.jpg" height="180">
<img src=".github/images/screenshot-phone-notification.jpg" height="180">
<img src="web/public/static/img/screenshot-curl.png" height="180">
<img src="web/public/static/img/screenshot-web-detail.png" height="180">
<img src="web/public/static/img/screenshot-phone-main.jpg" height="180">
<img src="web/public/static/img/screenshot-phone-detail.jpg" height="180">
<img src="web/public/static/img/screenshot-phone-notification.jpg" height="180">
</p>
## [ntfy Pro](https://ntfy.sh/app) 💸 🎉
I now offer paid plans for [ntfy.sh](https://ntfy.sh/) if you don't want to self-host, or you want to support the development of ntfy (→ [Purchase via web app](https://ntfy.sh/app)). You can **buy a plan for as low as $3.33/month** (if you use promo code `MYTOPIC`, limited time only). You can also donate via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), and [Liberapay](https://liberapay.com/ntfy). I would be very humbled by your sponsorship. ❤️
## **[Documentation](https://ntfy.sh/docs/)**
[Getting started](https://ntfy.sh/docs/) |
@ -123,24 +117,6 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
<a href="https://github.com/oakd"><img src="https://github.com/oakd.png" width="40px" /></a>
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
<a href="https://github.com/nichu42"><img src="https://github.com/nichu42.png" width="40px" /></a>
<a href="https://github.com/samliebow"><img src="https://github.com/samliebow.png" width="40px" /></a>
<a href="https://github.com/johman10"><img src="https://github.com/johman10.png" width="40px" /></a>
<a href="https://github.com/R-Gld"><img src="https://github.com/R-Gld.png" width="40px" /></a>
<a href="https://github.com/FingerlessGlov3s"><img src="https://github.com/FingerlessGlov3s.png" width="40px" /></a>
<a href="https://github.com/Twisterado"><img src="https://github.com/Twisterado.png" width="40px" /></a>
<a href="https://github.com/ScrumpyJack"><img src="https://github.com/ScrumpyJack.png" width="40px" /></a>
<a href="https://github.com/andrejarrell"><img src="https://github.com/andrejarrell.png" width="40px" /></a>
<a href="https://github.com/oaustegard"><img src="https://github.com/oaustegard.png" width="40px" /></a>
<a href="https://github.com/CreativeWarlock"><img src="https://github.com/CreativeWarlock.png" width="40px" /></a>
<a href="https://github.com/darkdragon-001"><img src="https://github.com/darkdragon-001.png" width="40px" /></a>
<a href="https://github.com/jonathan-kosgei"><img src="https://github.com/jonathan-kosgei.png" width="40px" /></a>
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:

View File

@ -1,10 +0,0 @@
# 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`).

View File

@ -11,25 +11,23 @@ import (
"heckel.io/ntfy/util"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// Event type constants
const (
// MessageEvent identifies a message event
MessageEvent = "message"
MessageEvent = "message"
KeepaliveEvent = "keepalive"
OpenEvent = "open"
PollRequestEvent = "poll_request"
)
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
@ -98,14 +96,8 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
// 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
}
topicURL := c.expandTopicURL(topic)
req, _ := http.NewRequest("POST", topicURL, body)
for _, option := range options {
if err := option(req); err != nil {
return nil, err
@ -141,14 +133,11 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
// 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)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll())
go func() {
@ -177,18 +166,15 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
// Example:
//
// c := client.New(client.NewConfig())
// subscriptionID, _ := c.Subscribe("mytopic")
// 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
}
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
c.mu.Lock()
defer c.mu.Unlock()
subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{
@ -197,7 +183,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, er
cancel: cancel,
}
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
return subscriptionID, nil
return subscriptionID
}
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
@ -213,16 +199,31 @@ func (c *Client) Unsubscribe(subscriptionID string) {
sub.cancel()
}
func (c *Client) expandTopicURL(topic string) (string, error) {
// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe.
// If there are multiple subscriptions matching the topic, all of them are unsubscribed from.
//
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
func (c *Client) UnsubscribeAll(topic string) {
c.mu.Lock()
defer c.mu.Unlock()
topicURL := c.expandTopicURL(topic)
for _, sub := range c.subscriptions {
if sub.topicURL == topicURL {
delete(c.subscriptions, sub.ID)
sub.cancel()
}
}
}
func (c *Client) expandTopicURL(topic string) string {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
return topic, nil
return topic
} else if strings.Contains(topic, "/") {
return fmt.Sprintf("https://%s", topic), nil
return fmt.Sprintf("https://%s", topic)
}
if !topicRegex.MatchString(topic) {
return "", fmt.Errorf("invalid topic name: %s", topic)
}
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
}
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {

View File

@ -5,12 +5,10 @@
#
# 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 ("")
# default-token:
# Default username and password will be used with "ntfy publish" if no credentials are provided on command line
# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below
# For an empty password, use empty double-quotes ("")
#
# default-user:
# default-password:
@ -32,8 +30,6 @@
# command: 'notify-send "$m"'
# user: phill
# password: mypass
# - topic: token_topic
# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
#
# Variables:
# Variable Aliases Description

View File

@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) {
defer test.StopServer(t, s, port)
c := client.New(newTestConfig(port))
subscriptionID, _ := c.Subscribe("mytopic")
subscriptionID := c.Subscribe("mytopic")
time.Sleep(time.Second)
msg, err := c.Publish("mytopic", "some message")

View File

@ -12,22 +12,17 @@ const (
// 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"`
DefaultHost string `yaml:"default-host"`
DefaultUser string `yaml:"default-user"`
DefaultPassword *string `yaml:"default-password"`
DefaultCommand string `yaml:"default-command"`
Subscribe []struct {
Topic string `yaml:"topic"`
User string `yaml:"user"`
Password *string `yaml:"password"`
Command string `yaml:"command"`
If map[string]string `yaml:"if"`
} `yaml:"subscribe"`
}
// NewConfig creates a new Config struct for a Client
@ -36,7 +31,6 @@ func NewConfig() *Config {
DefaultHost: DefaultBaseURL,
DefaultUser: "",
DefaultPassword: nil,
DefaultToken: "",
DefaultCommand: "",
Subscribe: nil,
}

View File

@ -116,25 +116,3 @@ subscribe:
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.Equal(t, "", conf.Subscribe[0].User)
require.Nil(t, conf.Subscribe[0].Password)
require.Equal(t, "", conf.Subscribe[0].Token)
}

View File

@ -40,6 +40,7 @@ var flagsPublish = append(
&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: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
)
@ -154,7 +155,8 @@ func execPublish(c *cli.Context) error {
}
if token != "" {
options = append(options, client.WithBearerAuth(token))
} else if user != "" {
}
if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)
if len(parts) == 2 {
@ -170,8 +172,6 @@ func execPublish(c *cli.Context) error {
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))
}

View File

@ -5,11 +5,8 @@ import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
@ -133,11 +130,11 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
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)
require.Nil(t, os.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"}))
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
m = toMessage(t, stdout.String())
require.Equal(t, "mytopic", m.Topic)
@ -146,155 +143,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
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)}))
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--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())
}

View File

@ -37,8 +37,8 @@ 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-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-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"}),
@ -59,12 +59,11 @@ var flagsServe = append(
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.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
altsrc.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)"}),
@ -72,10 +71,6 @@ var flagsServe = append(
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"}),
@ -86,14 +81,9 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.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)"}),
)
var cmdServe = &cli.Command{
@ -149,7 +139,6 @@ func execServe(c *cli.Context) error {
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")
@ -157,13 +146,8 @@ func execServe(c *cli.Context) error {
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")
@ -175,10 +159,6 @@ func execServe(c *cli.Context) error {
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) {
@ -195,8 +175,8 @@ func execServe(c *cli.Context) error {
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 smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
} else if smtpServerListen != "" && smtpServerDomain == "" {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
@ -205,6 +185,8 @@ func execServe(c *cli.Context) error {
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 !util.Contains([]string{"app", "home", "disable"}, webRoot) {
return errors.New("if set, web-root must be 'home' or 'app'")
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
@ -219,20 +201,10 @@ func execServe(c *cli.Context) error {
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
}
webRootIsApp := webRoot == "app"
enableWeb := webRoot != "disable"
// Default auth permissions
authDefault, err := user.ParsePermission(authDefaultAccess)
@ -278,7 +250,6 @@ func execServe(c *cli.Context) error {
// Stripe things
if stripeSecretKey != "" {
stripe.EnableTelemetry = false // Whoa!
stripe.Key = stripeSecretKey
}
@ -311,9 +282,8 @@ func execServe(c *cli.Context) error {
conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval
conf.DisallowedTopics = disallowedTopics
conf.WebRoot = webRoot
conf.WebRootIsApp = webRootIsApp
conf.UpstreamBaseURL = upstreamBaseURL
conf.UpstreamAccessToken = upstreamAccessToken
conf.SMTPSenderAddr = smtpSenderAddr
conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass
@ -321,10 +291,6 @@ func execServe(c *cli.Context) error {
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
@ -335,17 +301,13 @@ func execServe(c *cli.Context) error {
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.EnableWeb = enableWeb
conf.EnableSignup = enableSignup
conf.EnableLogin = enableLogin
conf.EnableReservations = enableReservations
conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.Version = c.App.Version
// Set up hot-reloading of config

View File

@ -30,7 +30,6 @@ var flagsSubscribe = append(
&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"},
@ -72,7 +71,7 @@ ntfy subscribe TOPIC COMMAND
$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
$NTFY_RAW $raw Raw JSON message
Examples:
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
@ -98,18 +97,11 @@ func execSubscribe(c *cli.Context) error {
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
}
@ -117,9 +109,7 @@ func execSubscribe(c *cli.Context) error {
if since != "" {
options = append(options, client.WithSince(since))
}
if token != "" {
options = append(options, client.WithBearerAuth(token))
} else if user != "" {
if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)
if len(parts) == 2 {
@ -135,10 +125,9 @@ func execSubscribe(c *cli.Context) error {
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 poll {
options = append(options, client.WithPoll())
}
if scheduled {
options = append(options, client.WithScheduled())
@ -156,9 +145,6 @@ func execSubscribe(c *cli.Context) error {
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
}
@ -189,15 +175,22 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
for filter, value := range s.If {
topicOptions = append(topicOptions, client.WithFilter(filter, value))
}
if auth := maybeAddAuthHeader(s, conf); auth != nil {
topicOptions = append(topicOptions, auth)
var user string
var password *string
if s.User != "" {
user = s.User
} else if conf.DefaultUser != "" {
user = conf.DefaultUser
}
subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
if err != nil {
return err
if s.Password != nil {
password = s.Password
} else if conf.DefaultPassword != nil {
password = conf.DefaultPassword
}
if user != "" && password != nil {
topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
}
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
if s.Command != "" {
cmds[subscriptionID] = s.Command
} else if conf.DefaultCommand != "" {
@ -207,10 +200,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
}
}
if topic != "" {
subscriptionID, err := cl.Subscribe(topic, options...)
if err != nil {
return err
}
subscriptionID := cl.Subscribe(topic, options...)
cmds[subscriptionID] = command
}
for m := range cl.Messages {
@ -224,25 +214,6 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
return nil
}
func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
// check for subscription token then subscription user:pass
if s.Token != "" {
return client.WithBearerAuth(s.Token)
}
if 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)

View File

@ -1,361 +0,0 @@
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", "--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", "--config=" + filename, "mytopic"}))
require.Equal(t, message, strings.TrimSpace(stdout.String()))
}

View File

@ -8,6 +8,7 @@ import (
"github.com/urfave/cli/v2"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"time"
)
func init() {
@ -16,13 +17,12 @@ func init() {
const (
defaultMessageLimit = 5000
defaultMessageExpiryDuration = "12h"
defaultMessageExpiryDuration = 12 * time.Hour
defaultEmailLimit = 20
defaultCallLimit = 0
defaultReservationLimit = 3
defaultAttachmentFileSizeLimit = "15M"
defaultAttachmentTotalSizeLimit = "100M"
defaultAttachmentExpiryDuration = "6h"
defaultAttachmentExpiryDuration = 6 * time.Hour
defaultAttachmentBandwidthLimit = "1G"
)
@ -47,16 +47,14 @@ var cmdTier = &cli.Command{
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.DurationFlag{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.DurationFlag{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.StringFlag{Name: "stripe-price-id", Usage: "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.
@ -91,16 +89,14 @@ Examples:
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.DurationFlag{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.DurationFlag{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)"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
},
Description: `Updates a tier to change the limits.
@ -114,8 +110,7 @@ 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 \
--stripe-price-id=price_1234 \
pro
`,
},
@ -171,10 +166,6 @@ func execTierAdd(c *cli.Context) error {
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 {
@ -191,10 +182,6 @@ func execTierAdd(c *cli.Context) error {
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
@ -207,25 +194,19 @@ func execTierAdd(c *cli.Context) error {
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,
MessageExpiryDuration: c.Duration("message-expiry-duration"),
EmailLimit: c.Int64("email-limit"),
CallLimit: c.Int64("call-limit"),
ReservationLimit: c.Int64("reservation-limit"),
AttachmentFileSizeLimit: attachmentFileSizeLimit,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
AttachmentExpiryDuration: attachmentExpiryDuration,
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
AttachmentBandwidthLimit: attachmentBandwidthLimit,
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
StripePriceID: c.String("stripe-price-id"),
}
if err := manager.AddTier(tier); err != nil {
return err
@ -263,17 +244,11 @@ func execTierChange(c *cli.Context) error {
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
}
tier.MessageExpiryDuration = c.Duration("message-expiry-duration")
}
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")
}
@ -290,10 +265,7 @@ func execTierChange(c *cli.Context) error {
}
}
if c.IsSet("attachment-expiry-duration") {
tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration"))
if err != nil {
return err
}
tier.AttachmentExpiryDuration = c.Duration("attachment-expiry-duration")
}
if c.IsSet("attachment-bandwidth-limit") {
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
@ -301,16 +273,8 @@ func execTierChange(c *cli.Context) error {
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 c.IsSet("stripe-price-id") {
tier.StripePriceID = c.String("stripe-price-id")
}
if err := manager.UpdateTier(tier); err != nil {
return err
@ -355,20 +319,19 @@ func execTierList(c *cli.Context) error {
}
func printTier(c *cli.Context, tier *user.Tier) {
prices := "(none)"
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
stripePriceID := tier.StripePriceID
if stripePriceID == "" {
stripePriceID = "(none)"
}
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)
fmt.Fprintf(c.App.ErrWriter, "- Stripe price: %s\n", stripePriceID)
}

View File

@ -29,25 +29,24 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "change",
"--message-limit=999",
"--message-expiry-duration=2d",
"--message-expiry-duration=99h",
"--email-limit=91",
"--reservation-limit=98",
"--attachment-file-size-limit=100m",
"--attachment-expiry-duration=1d",
"--attachment-expiry-duration=7h",
"--attachment-total-size-limit=10G",
"--attachment-bandwidth-limit=100G",
"--stripe-monthly-price-id=price_991",
"--stripe-yearly-price-id=price_992",
"--stripe-price-id=price_991",
"pro",
))
require.Contains(t, stderr.String(), "- Message limit: 999")
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
require.Contains(t, stderr.String(), "- Message expiry duration: 99h")
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 expiry duration: 7h")
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")
require.Contains(t, stderr.String(), "- Stripe price: price_991")
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))

View File

@ -1,50 +0,0 @@
{% extends "base.html" %}
{% block announce %}
<style>
div[data-md-component="announce"] {
z-index: 10;
}
div[data-md-component="announce"] a {
color: white;
}
div[data-md-component="announce"] a:hover, div[data-md-component="announce"] a:focus {
transition: ease-in 150ms;
color: #ccc;
}
div[data-md-component="announce"] .md-banner__button {
color: #ccc;
}
div[data-md-component="announce"] .md-banner.hidden {
display: none;
}
div[data-md-component="announce"] .twemoji {
margin-top: 2px;
}
</style>
<button id="announce-bar-close" class="md-banner__button md-icon" aria-label="Don't show this again">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"></path>
</svg>
</button>
If you like ntfy, please consider sponsoring me via <a target="_blank" href="https://github.com/sponsors/binwiederhier"><strong>GitHub Sponsors</strong></a>
or <a target="_blank" href="https://en.liberapay.com/ntfy/"><strong>Liberapay</strong></a>
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 36 36" class="twemoji md-footer-custom-text">
<path fill="#DD2E44" d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>, or subscribing to <a target="_blank" href="https://ntfy.sh/app"><strong>ntfy Pro</strong></a>.
<script>
announceBarKey = 'announce-bar-closed-sponsor';
document.getElementById('announce-bar-close').addEventListener('click', (e) => {
localStorage.setItem(announceBarKey, 'true');
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
});
if (localStorage.getItem(announceBarKey) === 'true') {
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
}
</script>
{% endblock %}

View File

@ -759,7 +759,6 @@ 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
@ -815,7 +814,6 @@ ntfy tier add \
--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 \
@ -841,8 +839,6 @@ config options:
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
@ -853,25 +849,8 @@ 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.
@ -950,25 +929,6 @@ 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: <topic1>,<topic2>,...` 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**.
@ -1107,60 +1067,6 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
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 `health` field is `false` the ntfy service should be considered as unhealthy.
```json
{"health":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)):
<figure markdown style="padding-left: 50px; padding-right: 50px">
<a href="../../static/img/grafana-dashboard.png" target="_blank"><img src="../../static/img/grafana-dashboard.png"/></a>
<figcaption>ntfy Grafana dashboard</figcaption>
</figure>
## 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://<ip>:<port>/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.
@ -1259,15 +1165,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `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 |
@ -1277,14 +1178,12 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `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) |
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
| `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 |
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
@ -1368,7 +1267,6 @@ OPTIONS:
--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]
--help, -h show help (default: false)
```

View File

@ -163,15 +163,6 @@ $ make release-snapshot
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:

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ I started adding notifications pretty much all of my scripts. Typically, I just
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
``` bash
```
rsync -a root@laptop /backups/laptop \
&& zfs snapshot ... \
&& curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \
@ -26,7 +26,7 @@ rsync -a root@laptop /backups/laptop \
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
```
``` cron
# Check github/ntfy user
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
```
@ -136,33 +136,27 @@ You can send a message during a workflow run with curl. Here is an example sendi
```
## Watchtower (shoutrrr)
You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support 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
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
```
Or, if you only want to send notifications using shoutrrr:
```
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
```
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
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).
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
Some simple bash scripts to achieve this are kindly provided in [nickexyz's 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:
@ -578,27 +572,4 @@ Example `template.html`:
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
<entry key='sms.http.url'>https://ntfy.sh</entry>
<entry key='sms.http.template'>
{
"topic": "{phone}",
"message": "{message}"
}
</entry>
```
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
<entry key='sms.http.authorization'>Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ</entry>
```
or by simply providing traccar with a valid username/password combination.
```xml
<entry key='sms.http.user'>phil</entry>
<entry key='sms.http.password'>mypass</entry>
```

View File

@ -43,9 +43,9 @@ 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.
or you use *instant delivery* (Android only), 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

View File

@ -1,6 +0,0 @@
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'))

View File

@ -20,46 +20,43 @@ To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` wh
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 video tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU).
It's short and to the point. _I am not affiliated with Kris, I just liked the video._
## 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.5.0/ntfy_2.5.0_linux_x86_64.tar.gz
tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz
sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_x86_64.tar.gz
tar zxvf ntfy_2.0.0_linux_x86_64.tar.gz
sudo cp -a ntfy_2.0.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz
tar zxvf ntfy_2.5.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.tar.gz
tar zxvf ntfy_2.0.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.0.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.tar.gz
tar zxvf ntfy_2.5.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.tar.gz
tar zxvf ntfy_2.0.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.0.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.tar.gz
tar zxvf ntfy_2.5.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.tar.gz
tar zxvf ntfy_2.0.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.0.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@ -109,7 +106,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -117,7 +114,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -125,7 +122,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -133,7 +130,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -143,28 +140,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.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.5.0/ntfy_2.5.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.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.5.0/ntfy_2.5.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.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.5.0/ntfy_2.5.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@ -192,36 +189,30 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## 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.5.0/ntfy_2.5.0_macOS_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_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.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz
tar zxvf ntfy_2.5.0_macOS_all.tar.gz
sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz > ntfy_2.0.0_macOS_all.tar.gz
tar zxvf ntfy_2.0.0_macOS_all.tar.gz
sudo cp -a ntfy_2.0.0_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.0.0_macOS_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
```
There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via
[Homebrew](https://brew.sh/). I'll eventually get to that, but I'd also love if somebody else stepped up to do it.
Also, you can build and run the ntfy server on macOS as well, though I don't officially support that.
Check out the [build instructions](develop.md) for details.
## 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.5.0/ntfy_2.5.0_windows_x86_64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_windows_x86_64.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).
@ -275,7 +266,7 @@ docker run \
serve
```
Using docker-compose with non-root user and healthchecks enabled:
Using docker-compose with non-root user:
```yaml
version: "2.1"
@ -293,12 +284,6 @@ services:
- /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
```

View File

@ -4,6 +4,22 @@ There are quite a few projects that work with ntfy, integrate ntfy, or have been
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
## Public 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 |
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**.
## Official integrations
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
@ -17,20 +33,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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
## 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
@ -54,7 +58,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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)
## CLIs + GUIs
@ -70,7 +73,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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)
@ -80,7 +82,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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)
@ -104,38 +105,15 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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)
## Blog + forum posts
- [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
@ -144,12 +122,10 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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
- [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
@ -187,22 +163,3 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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 |
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**.

View File

@ -8,7 +8,7 @@ For some (many?) users, the iOS app is not refreshing the view when new notifica
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.
clueless on how to fix it, sadly, as it is ephemeral and now clear to me what is causing it.
Please send experienced iOS developers my way to help me figure this out.

View File

@ -38,12 +38,7 @@ Here's an example showing how to publish a simple message using a POST request:
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/mytopic"
Body = "Backup successful"
}
Invoke-RestMethod @Request
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/mytopic -Body "Backup successful" -UseBasicParsing
```
=== "Python"
@ -129,17 +124,12 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
=== "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
$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 -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -252,21 +242,18 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
=== "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
$uri = "https://ntfy.sh/mydoorbell"
$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.
'@
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -355,15 +342,10 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/controversial"
Headers = @{
Title = "Dogs are better than cats"
}
Body = "Oh my ..."
}
Invoke-RestMethod @Request
$uri = "https://ntfy.sh/controversial"
$headers = @{ Title="Dogs are better than cats" }
$body = "Oh my ..."
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -391,12 +373,6 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
<figcaption>Detail view of notification with title</figcaption>
</figure>
!!! 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:
@ -456,14 +432,10 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
=== "PowerShell"
``` powershell
$Request = @{
URI = "https://ntfy.sh/phil_alerts"
Headers = @{
Priority = "5"
}
Body = "An urgent message"
}
Invoke-RestMethod @Request
$uri = "https://ntfy.sh/phil_alerts"
$headers = @{ Priority="5" }
$body = "An urgent message"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -581,15 +553,10 @@ them with a comma, e.g. `tag1,tag2,tag3`.
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/backups"
Headers = @{
Tags = "warning,mailsrv13,daily-backup"
}
Body = "Backup of mailsrv13 failed"
}
Invoke-RestMethod @Request
$uri = "https://ntfy.sh/backups"
$headers = @{ Tags="warning,mailsrv13,daily-backup" }
$body = "Backup of mailsrv13 failed"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -617,12 +584,6 @@ them with a comma, e.g. `tag1,tag2,tag3`.
<figcaption>Detail view of notifications with tags</figcaption>
</figure>
!!! 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)).
## Scheduled delivery
_Supported on:_ :material-android: :material-apple: :material-firefox:
@ -684,15 +645,10 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/hello"
Headers = @{
At = "tomorrow, 10am"
}
Body = "Good morning"
}
Invoke-RestMethod @Request
$uri = "https://ntfy.sh/hello"
$headers = @{ At="tomorrow, 10am" }
$body = "Good morning"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -773,7 +729,7 @@ For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhoo
=== "PowerShell"
``` powershell
Invoke-RestMethod "ntfy.sh/mywebhook/trigger"
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/trigger"
```
=== "Python"
@ -822,7 +778,7 @@ Here's an example with a custom message, tags and a priority:
=== "PowerShell"
``` powershell
Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
```
=== "Python"
@ -927,29 +883,25 @@ is the only required one:
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh"
Body = @{
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 = ConvertTo-JSON @(
@{
Action = "view"
Label = "Admin panel"
URL = "https://filesrv.lan/admin"
}
$uri = "https://ntfy.sh"
$body = @{
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
} | ConvertTo-Json
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
@ -1004,11 +956,9 @@ all the supported fields:
| `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) |
| `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:
@ -1111,15 +1061,10 @@ As an example, here's how you can create the above notification using this forma
=== "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
$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 -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -1141,13 +1086,7 @@ As an example, here's how you can create the above notification using this forma
]
]));
```
!!! 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)):
@ -1275,30 +1214,26 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
=== "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}'
}
$uri = "https://ntfy.sh"
$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}'
}
)
}
ContentType = "application/json"
}
Invoke-RestMethod @Request
} | ConvertTo-Json
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
@ -1357,7 +1292,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
```
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)
[`view` action](#open-websiteapp), [`broadcasst` action](#send-android-broadcast), and [`http` action](#send-http-request)
for details.
### Open website/app
@ -1423,15 +1358,10 @@ Here's an example using the [`X-Actions` header](#using-a-header):
=== "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
$uri = "https://ntfy.sh/myhome"
$headers = @{ Actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" }
$body = "Somebody retweeted your tweet."
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -1544,23 +1474,19 @@ And the same example using [JSON publishing](#publish-as-json):
=== "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"
}
$uri = "https://ntfy.sh"
$body = @{
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
} | ConvertTo-Json
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
@ -1674,15 +1600,10 @@ Here's an example using the [`X-Actions` header](#using-a-header):
=== "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
$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 -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -1812,26 +1733,23 @@ And the same example using [JSON publishing](#publish-as-json):
``` 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 = @{
Topic = "wifey"
Message = "Your wife requested you send a picture of yourself."
Actions = ConvertTo-Json -Depth 3 @(
@{
Action = "broadcast"
Label = "Take picture"
Extras = @{
CMD ="pic"
Camera = "front"
$uri = "https://ntfy.sh"
$body = @{
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
} | ConvertTo-Json -Depth 3
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
@ -1943,15 +1861,10 @@ Here's an example using the [`X-Actions` header](#using-a-header):
=== "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
$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 -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -2092,28 +2005,24 @@ And the same example using [JSON publishing](#publish-as-json):
# 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 = @{
Topic = "myhome"
Message = "Garage door has been open for 15 minutes. Close it?"
Actions = ConvertTo-Json -Depth 3 @(
@{
Action = "http"
Label = "Close door"
URL = "https://api.mygarage.lan/"
Method = "PUT"
Headers = @{
Authorization = "Bearer zAzsx1sk.."
$uri = "https://ntfy.sh"
$body = @{
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"}'
}
Body = ConvertTo-JSON @{Action = "close"}
}
)
}
ContentType = "application/json"
}
Invoke-RestMethod @Request
} | ConvertTo-Json -Depth 3
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
@ -2240,13 +2149,10 @@ Here's an example that will open Reddit when the notification is clicked:
=== "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
$uri = "https://ntfy.sh/reddit_alerts"
$headers = @{ Click="https://www.reddit.com/message/messages" }
$body = "New messages on Reddit"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -2415,12 +2321,9 @@ Here's an example showing how to attach an APK file:
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/mydownloads"
Headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
}
Invoke-RestMethod @Request
$uri = "https://ntfy.sh/mydownloads"
$headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing
```
=== "Python"
@ -2511,17 +2414,12 @@ Here's an example showing how to include an icon:
=== "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
$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 -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
@ -2627,18 +2525,13 @@ that, your IP address appears in the e-mail body. This is to prevent abuse.
=== "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
$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 -Method 'Post' -Uri $uri -Body $body -UseBasicParsing
```
=== "Python"
@ -2689,11 +2582,6 @@ 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)):
@ -2703,133 +2591,6 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
<figcaption>Publishing a message via e-mail</figcaption>
</figure>
## 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.
<figure markdown>
![phone number verification](static/img/web-phone-verify.png)
<figcaption>Phone number verification in the <a href="https://ntfy.sh/account">web app</a></figcaption>
</figure>
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 controls>
<source src="../static/audio/ntfy-phone-call.mp3" type="audio/mpeg">
<source src="../static/audio/ntfy-phone-call.ogg" type="audio/ogg">
</audio>
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.
@ -2843,7 +2604,7 @@ To publish/subscribe to protected topics, you can:
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
### 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`:
@ -2891,37 +2652,14 @@ Here's an example with a user `testuser` and password `fakepassword`:
http.DefaultClient.Do(req)
```
=== "PowerShell 7+"
=== "PowerShell"
``` 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
$uri = "https://ntfy.example.com/mysecrets"
$credentials = 'testuser:fakepassword'
$encodedCredentials = [convert]::ToBase64String([text.Encoding]::UTF8.GetBytes($credentials))
$headers = @{Authorization="Basic $encodedCredentials"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
@ -3018,29 +2756,12 @@ with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:
http.DefaultClient.Do(req)
```
=== "PowerShell 7+"
=== "PowerShell"
``` 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
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
@ -3115,16 +2836,10 @@ access token. This is primarily useful to make `curl` calls easier, e.g. `curl -
=== "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
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
@ -3193,12 +2908,9 @@ Here's an example using the `auth` query parameter:
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw"
Body = "Look ma, with auth"
}
Invoke-RestMethod @Request
$uri = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw"
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Method "Post" -UseBasicParsing
```
=== "Python"
@ -3218,7 +2930,7 @@ Here's an example using the `auth` query parameter:
]));
```
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see anove) using
**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully
explains it better:
@ -3238,12 +2950,6 @@ The following command will generate the appropriate value for you on *nix system
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
@ -3301,13 +3007,10 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
=== "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
$uri = "https://ntfy.sh/mytopic"
$headers = @{ Cache="no" }
$body = "This message won't be stored server-side"
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
```
=== "Python"
@ -3384,13 +3087,10 @@ to `no`. This will instruct the server not to forward messages to Firebase.
=== "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
$uri = "https://ntfy.sh/mytopic"
$headers = @{ Firebase="no" }
$body = "This message won't be forwarded to FCM"
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
```
=== "Python"
@ -3456,34 +3156,22 @@ There are a few limitations to the API to prevent abuse and to keep the server h
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.
| 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. |
| **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. |
| **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. |
| **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. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
## 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.
The following is a list of all parameters that can be passed when publishing 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.
!!! 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 |
| Parameter | Aliases (case-insensitive) | Description |
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
@ -3496,7 +3184,6 @@ table in their canonical form.
| `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 |

View File

@ -2,216 +2,6 @@
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.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 suport 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
@ -274,11 +64,6 @@ going. It'll only make ntfy better.
* 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
@ -310,6 +95,11 @@ breaking-change upgrade, which required some work to get working again.
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
**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, and @xenrox.
## ntfy server v1.30.1
Released December 23, 2022 🎅
@ -1201,36 +991,3 @@ Released Dec 28, 2021
## 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 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/))
### ntfy server v2.6.0 (UNRELEASED)
**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)
**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))

Binary file not shown.

Binary file not shown.

View File

@ -2,15 +2,13 @@
--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;
}
@ -71,18 +69,7 @@ figure video {
}
.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;
padding: 0 10px
}
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
@ -160,57 +147,3 @@ figure video {
.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');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/static/img/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@ -319,7 +319,7 @@ format of the message. It's very straight forward:
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `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 |
| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted |
| `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 |

View File

@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
<figcaption>Execute all the things</figcaption>
</figure>
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.
If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the
`client.yml`. If a subscription does not specify a username/password 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.
Because the `default-user` and `default-password` 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))

View File

@ -1,131 +0,0 @@
# 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.
<figure markdown>
![Recording logs on Android](static/img/android-screenshot-logs.jpg){ width=400 }
<figcaption>Recording logs on Android</figcaption>
</figure>
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.
<figure markdown>
![Web app logs](static/img/web-logs.png)
<figcaption>Web app logs in the developer console</figcaption>
</figure>
## 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.

File diff suppressed because it is too large Load Diff

57
go.mod
View File

@ -4,71 +4,62 @@ go 1.18
require (
cloud.google.com/go/firestore v1.9.0 // indirect
cloud.google.com/go/storage v1.30.1 // indirect
cloud.google.com/go/storage v1.29.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.16.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/gabriel-vasile/mimetype v1.4.1
github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.16
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.25.3
golang.org/x/crypto v0.9.0
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sync v0.2.0
golang.org/x/term v0.8.0
github.com/urfave/cli/v2 v2.24.4
golang.org/x/crypto v0.6.0
golang.org/x/oauth2 v0.5.0 // indirect
golang.org/x/sync v0.1.0
golang.org/x/term v0.5.0
golang.org/x/time v0.3.0
google.golang.org/api v0.122.0
google.golang.org/api v0.110.0
gopkg.in/yaml.v2 v2.4.0
)
require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.11.0
github.com/prometheus/client_golang v1.15.1
github.com/stripe/stripe-go/v74 v74.18.0
firebase.google.com/go/v4 v4.10.0
github.com/stripe/stripe-go/v74 v74.7.0
)
require (
cloud.google.com/go v0.110.2 // indirect
cloud.google.com/go/compute v1.19.3 // indirect
cloud.google.com/go v0.110.0 // indirect
cloud.google.com/go/compute v1.18.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.0.1 // indirect
cloud.google.com/go/longrunning v0.4.2 // indirect
cloud.google.com/go/iam v0.10.0 // indirect
cloud.google.com/go/longrunning v0.4.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/s2a-go v0.1.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.43.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.3 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.55.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

168
go.sum
View File

@ -1,21 +1,20 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -23,23 +22,11 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -51,15 +38,12 @@ github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVR
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -68,19 +52,16 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -91,126 +72,89 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE=
github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us=
github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/stripe/stripe-go/v74 v74.7.0 h1:KHlyslQj9YOv62b1sycQ31LFj7KlqR+seHsSowAWrjc=
github.com/stripe/stripe-go/v74 v74.7.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -218,37 +162,29 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine/v2 v2.0.3 h1:AyY/mipuqiyCIAqOevfmu5fMDc5/9P/QggWfCQYdkSA=
google.golang.org/appengine/v2 v2.0.3/go.mod h1:2Z0TTdcXxnHdXzmp8drrmOExUDM2WQgyT33c6JDUlJM=
google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk=
google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -260,12 +196,10 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -3,7 +3,6 @@ package log
import (
"encoding/json"
"fmt"
"heckel.io/ntfy/util"
"log"
"os"
"sort"
@ -12,11 +11,11 @@ import (
)
const (
fieldTag = "tag"
fieldError = "error"
fieldTimeTaken = "time_taken_ms"
fieldExitCode = "exit_code"
tagStdLog = "stdlog"
tagField = "tag"
errorField = "error"
timeTakenField = "time_taken_ms"
exitCodeField = "exit_code"
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
)
// Event represents a single log event
@ -41,39 +40,39 @@ func newEvent() *Event {
// Fatal logs the event as FATAL, and exits the program with exit code 1
func (e *Event) Fatal(message string, v ...any) {
e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)
e.Field(exitCodeField, 1).maybeLog(FatalLevel, message, v...)
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
os.Exit(1)
}
// Error logs the event with log level error
func (e *Event) Error(message string, v ...any) *Event {
return e.Log(ErrorLevel, message, v...)
func (e *Event) Error(message string, v ...any) {
e.maybeLog(ErrorLevel, message, v...)
}
// Warn logs the event with log level warn
func (e *Event) Warn(message string, v ...any) *Event {
return e.Log(WarnLevel, message, v...)
func (e *Event) Warn(message string, v ...any) {
e.maybeLog(WarnLevel, message, v...)
}
// Info logs the event with log level info
func (e *Event) Info(message string, v ...any) *Event {
return e.Log(InfoLevel, message, v...)
func (e *Event) Info(message string, v ...any) {
e.maybeLog(InfoLevel, message, v...)
}
// Debug logs the event with log level debug
func (e *Event) Debug(message string, v ...any) *Event {
return e.Log(DebugLevel, message, v...)
func (e *Event) Debug(message string, v ...any) {
e.maybeLog(DebugLevel, message, v...)
}
// Trace logs the event with log level trace
func (e *Event) Trace(message string, v ...any) *Event {
return e.Log(TraceLevel, message, v...)
func (e *Event) Trace(message string, v ...any) {
e.maybeLog(TraceLevel, message, v...)
}
// Tag adds a "tag" field to the log event
func (e *Event) Tag(tag string) *Event {
return e.Field(fieldTag, tag)
return e.Field(tagField, tag)
}
// Time sets the time field
@ -86,7 +85,7 @@ func (e *Event) Time(t time.Time) *Event {
func (e *Event) Timing(f func()) *Event {
start := time.Now()
f()
return e.Field(fieldTimeTaken, time.Since(start).Milliseconds())
return e.Field(timeTakenField, time.Since(start).Milliseconds())
}
// Err adds an "error" field to the log event
@ -96,7 +95,7 @@ func (e *Event) Err(err error) *Event {
} else if c, ok := err.(Contexter); ok {
return e.With(c)
}
return e.Field(fieldError, err.Error())
return e.Field(errorField, err.Error())
}
// Field adds a custom field and value to the log event
@ -108,14 +107,6 @@ func (e *Event) Field(key string, value any) *Event {
return e
}
// FieldIf adds a custom field and value to the log event if the given level is loggable
func (e *Event) FieldIf(key string, value any, level Level) *Event {
if e.Loggable(level) {
return e.Field(key, value)
}
return e
}
// Fields adds a map of fields to the log event
func (e *Event) Fields(fields Context) *Event {
if e.fields == nil {
@ -127,46 +118,39 @@ func (e *Event) Fields(fields Context) *Event {
return e
}
// With adds the fields of the given Contexter structs to the log event by calling their Context method
func (e *Event) With(contexters ...Contexter) *Event {
// With adds the fields of the given Contexter structs to the log event by calling their With method
func (e *Event) With(contexts ...Contexter) *Event {
if e.contexters == nil {
e.contexters = contexters
e.contexters = contexts
} else {
e.contexters = append(e.contexters, contexters...)
e.contexters = append(e.contexters, contexts...)
}
return e
}
// Render returns the rendered log event as a string, or an empty string. The event is only rendered,
// if either the global log level is >= l, or if the log level in one of the overrides matches
// maybeLog logs the event to the defined output. The event is only logged, if
// either the global log level is >= l, or if the log level in one of the overrides matches
// the level.
//
// If no overrides are defined (default), the Contexter array is not applied unless the event
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
// to determine if they match. This is super complicated, but required for efficiency.
func (e *Event) Render(l Level, message string, v ...any) string {
func (e *Event) maybeLog(l Level, message string, v ...any) {
appliedContexters := e.maybeApplyContexters()
if !e.Loggable(l) {
return ""
if !e.shouldLog(l) {
return
}
e.Message = fmt.Sprintf(message, v...)
e.Level = l
e.Timestamp = util.FormatTime(e.time)
e.Timestamp = e.time.Format(timestampFormat)
if !appliedContexters {
e.applyContexters()
}
if CurrentFormat() == JSONFormat {
return e.JSON()
log.Println(e.JSON())
} else {
log.Println(e.String())
}
return e.String()
}
// Log logs the event to the defined output, or does nothing if Render returns an empty string
func (e *Event) Log(l Level, message string, v ...any) *Event {
if m := e.Render(l, message, v...); m != "" {
log.Println(m)
}
return e
}
// Loggable returns true if the given log level is lower or equal to the current log level
@ -208,6 +192,10 @@ func (e *Event) String() string {
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
}
func (e *Event) shouldLog(l Level) bool {
return e.globalLevelWithOverride() <= l
}
func (e *Event) globalLevelWithOverride() Level {
mu.RLock()
l, ov := level, overrides
@ -215,13 +203,11 @@ func (e *Event) globalLevelWithOverride() Level {
if e.fields == nil {
return l
}
for field, fieldOverrides := range ov {
for field, override := range ov {
value, exists := e.fields[field]
if exists {
for _, o := range fieldOverrides {
if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) {
return o.level
}
if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) {
return override.level
}
}
}

View File

@ -4,7 +4,6 @@ import (
"io"
"log"
"os"
"strings"
"sync"
"time"
)
@ -13,26 +12,17 @@ import (
var (
DefaultLevel = InfoLevel
DefaultFormat = TextFormat
DefaultOutput = &peekLogWriter{os.Stderr}
DefaultOutput = os.Stderr
)
var (
level = DefaultLevel
format = DefaultFormat
overrides = make(map[string][]*levelOverride)
overrides = make(map[string]*levelOverride)
output io.Writer = DefaultOutput
filename = ""
mu = &sync.RWMutex{}
)
// init sets the default log output (including log.SetOutput)
//
// This has to be explicitly called, because DefaultOutput is a peekLogWriter,
// which wraps os.Stderr.
func init() {
SetOutput(DefaultOutput)
}
// Fatal prints the given message, and exits the program
func Fatal(message string, v ...any) {
newEvent().Fatal(message, v...)
@ -111,17 +101,14 @@ func SetLevel(newLevel Level) {
func SetLevelOverride(field string, value string, level Level) {
mu.Lock()
defer mu.Unlock()
if _, ok := overrides[field]; !ok {
overrides[field] = make([]*levelOverride, 0)
}
overrides[field] = append(overrides[field], &levelOverride{value: value, level: level})
overrides[field] = &levelOverride{value: value, level: level}
}
// ResetLevelOverrides removes all log level overrides
func ResetLevelOverrides() {
mu.Lock()
defer mu.Unlock()
overrides = make(map[string][]*levelOverride)
overrides = make(map[string]*levelOverride)
}
// CurrentFormat returns the current log format
@ -145,27 +132,28 @@ func SetFormat(newFormat Format) {
func SetOutput(w io.Writer) {
mu.Lock()
defer mu.Unlock()
output = &peekLogWriter{w}
if f, ok := w.(*os.File); ok {
filename = f.Name()
} else {
filename = ""
}
log.SetOutput(output)
log.SetOutput(w)
output = w
}
// File returns the log file, if any, or an empty string otherwise
func File() string {
mu.RLock()
defer mu.RUnlock()
return filename
if f, ok := output.(*os.File); ok {
return f.Name()
}
return ""
}
// IsFile returns true if the output is a non-default file
func IsFile() bool {
mu.RLock()
defer mu.RUnlock()
return filename != ""
if _, ok := output.(*os.File); ok && output != DefaultOutput {
return true
}
return false
}
// DisableDates disables the date/time prefix
@ -187,20 +175,3 @@ func IsTrace() bool {
func IsDebug() bool {
return Loggable(DebugLevel)
}
// peekLogWriter is an io.Writer which will peek at the rendered log event,
// and ensure that the rendered output is valid JSON. This is a hack!
type peekLogWriter struct {
w io.Writer
}
func (w *peekLogWriter) Write(p []byte) (n int, err error) {
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
return w.w.Write(p)
}
m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p)))
if m == "" {
return 0, nil
}
return w.w.Write([]byte(m + "\n"))
}

View File

@ -4,10 +4,7 @@ import (
"bytes"
"encoding/json"
"github.com/stretchr/testify/require"
"io"
"log"
"os"
"path/filepath"
"testing"
"time"
)
@ -173,96 +170,6 @@ func TestLog_LevelOverrideAny(t *testing.T) {
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
`
require.Equal(t, expected, out.String())
require.False(t, IsFile())
require.Equal(t, "", File())
}
func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
SetLevelOverride("tag", "manager", DebugLevel)
SetLevelOverride("tag", "publish", DebugLevel)
Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged")
Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged")
Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged")
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"}
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"}
`
require.Equal(t, expected, out.String())
require.False(t, IsFile())
require.Equal(t, "", File())
}
func TestLog_FieldIf(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetLevel(DebugLevel)
SetFormat(JSONFormat)
Time(time.Unix(11, 0).UTC()).
FieldIf("trace_field", "manager", TraceLevel). // This is not logged
Field("tag", "manager").
Debug("trace_field is not logged")
SetLevel(TraceLevel)
Time(time.Unix(12, 0).UTC()).
FieldIf("trace_field", "manager", TraceLevel). // Now it is logged
Field("tag", "manager").
Debug("trace_field is logged")
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"}
{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"}
`
require.Equal(t, expected, out.String())
}
func TestLog_UsingStdLogger_JSON(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
log.Println("Some other library is using the standard Go logger")
require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n")
}
func TestLog_UsingStdLogger_Text(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
log.Println("Some other library is using the standard Go logger")
require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n")
require.NotContains(t, out.String(), `{`)
}
func TestLog_File(t *testing.T) {
t.Cleanup(resetState)
logfile := filepath.Join(t.TempDir(), "ntfy.log")
f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600)
require.Nil(t, err)
SetOutput(f)
SetFormat(JSONFormat)
require.True(t, IsFile())
require.Equal(t, logfile, File())
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged")
require.Nil(t, f.Close())
f, err = os.Open(logfile)
require.Nil(t, err)
contents, err := io.ReadAll(f)
require.Nil(t, err)
require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents))
}
type fakeError struct {

View File

@ -102,13 +102,6 @@ type Contexter interface {
// Context represents an object's state in the form of key-value pairs
type Context map[string]any
// Merge merges other into this context
func (c Context) Merge(other Context) {
for k, v := range other {
c[k] = v
}
}
type levelOverride struct {
value string
level Level

View File

@ -9,11 +9,9 @@ edit_uri: blob/main/docs/
theme:
name: material
font: false
language: en
custom_dir: docs/_overrides
logo: static/img/ntfy.png
favicon: static/img/favicon.ico
favicon: static/img/favicon.png
include_search_page: false
search_index_only: true
palette:
@ -71,9 +69,6 @@ plugins:
- search
- minify:
minify_html: true
- mkdocs-simple-hooks:
hooks:
on_post_build: "docs.hooks:copy_fonts"
nav:
- "Getting started": index.md
@ -93,7 +88,6 @@ nav:
- "Integrations + projects": integrations.md
- "Release notes": releases.md
- "Emojis 🥳 🎉": emojis.md
- "Troubleshooting": troubleshooting.md
- "Known issues": known-issues.md
- "Deprecation notices": deprecations.md
- "Development": develop.md

View File

@ -1,4 +1,3 @@
# The documentation uses 'mkdocs', which is written in Python
mkdocs-material
mkdocs-minify-plugin
mkdocs-simple-hooks

View File

@ -29,7 +29,7 @@ You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other
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/#tags-emojis).
<table class=\"remove-md-box emoji-table\"><tr>
<table class="remove-md-box"><tr>
" > "$1"
count="$(cat "$SCRIPTDIR/emoji.json" | jq -r '.[] | .emoji' | wc -l)"
@ -37,9 +37,9 @@ converted to emojis. This is a reference of all supported emojis. To learn more
for col in 0 1 2; do
from="$(($col * $percolumn + 1))"
to="$(($col * $percolumn + 1 + $percolumn))"
echo "<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>" >> "$1"
echo "<td><table><thead><tr><th>Tag</th><th>Emoji</th></tr></thead><tbody>" >> "$1"
cat "$SCRIPTDIR/emoji.json" \
| jq -r '.[] | "<tr><td class=c><code>" + .aliases[0] + "</code></td><td class=e>" + .emoji + "</td></tr>"' \
| jq -r '.[] | "<tr><td><code>" + .aliases[0] + "</code></td><td>" + .emoji + "</td></tr>"' \
| sed -n "${from},${to}p" >> "$1"
echo "</tbody></table></td>" >> "$1"
done

View File

@ -49,7 +49,7 @@ const (
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
DefaultVisitorAuthFailureLimitBurst = 30
DefaultVisitorAuthFailureLimitBurst = 10
DefaultVisitorAuthFailureLimitReplenish = time.Minute
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
@ -61,7 +61,7 @@ var (
// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
// extended using the server.yml config. If updated, also update in Android and web app.
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "metrics", "account", "settings", "signup", "login", "v1"}
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"}
)
// Config is the main config struct for the application. Use New to instantiate a default config struct.
@ -92,13 +92,12 @@ type Config struct {
KeepaliveInterval time.Duration
ManagerInterval time.Duration
DisallowedTopics []string
WebRoot string // empty to disable
WebRootIsApp bool
DelayedSenderInterval time.Duration
FirebaseKeepaliveInterval time.Duration
FirebasePollInterval time.Duration
FirebaseQuotaExceededPenaltyDuration time.Duration
UpstreamBaseURL string
UpstreamAccessToken string
SMTPSenderAddr string
SMTPSenderUser string
SMTPSenderPass string
@ -106,15 +105,6 @@ type Config struct {
SMTPServerListen string
SMTPServerDomain string
SMTPServerAddrPrefix string
TwilioAccount string
TwilioAuthToken string
TwilioPhoneNumber string
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
MessageLimit int
MinDelay time.Duration
MaxDelay time.Duration
@ -134,16 +124,14 @@ type Config struct {
VisitorAuthFailureLimitBurst int
VisitorAuthFailureLimitReplenish time.Duration
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
BehindProxy bool
StripeSecretKey string
StripeWebhookKey string
StripePriceCacheDuration time.Duration
BillingContact string
EnableWeb bool
EnableSignup bool // Enable creation of accounts via API and UI
EnableLogin bool
EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool
EnableReservations bool // Allow users with role "user" to own/reserve topics
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
Version string // injected by App
}
@ -177,13 +165,12 @@ func NewConfig() *Config {
KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval,
DisallowedTopics: DefaultDisallowedTopics,
WebRoot: "/",
WebRootIsApp: false,
DelayedSenderInterval: DefaultDelayedSenderInterval,
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
FirebasePollInterval: DefaultFirebasePollInterval,
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
UpstreamBaseURL: "",
UpstreamAccessToken: "",
SMTPSenderAddr: "",
SMTPSenderUser: "",
SMTPSenderPass: "",
@ -191,12 +178,6 @@ func NewConfig() *Config {
SMTPServerListen: "",
SMTPServerDomain: "",
SMTPServerAddrPrefix: "",
TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests
TwilioAccount: "",
TwilioAuthToken: "",
TwilioPhoneNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
MessageLimit: DefaultMessageLengthLimit,
MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay,
@ -216,12 +197,11 @@ func NewConfig() *Config {
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
VisitorSubscriberRateLimiting: false,
BehindProxy: false,
StripeSecretKey: "",
StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
BillingContact: "",
EnableWeb: true,
EnableSignup: false,
EnableLogin: false,
EnableReservations: false,

View File

@ -13,7 +13,6 @@ type errHTTP struct {
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
context log.Context
}
func (e errHTTP) Error() string {
@ -26,117 +25,71 @@ func (e errHTTP) JSON() string {
}
func (e errHTTP) Context() log.Context {
context := log.Context{
return log.Context{
"error": e.Message,
"error_code": e.Code,
"http_status": e.HTTPCode,
}
for k, v := range e.context {
context[k] = v
}
return context
}
func (e errHTTP) Wrap(message string, args ...any) *errHTTP {
clone := e.clone()
clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...))
return &clone
}
func (e errHTTP) With(contexters ...log.Contexter) *errHTTP {
c := e.clone()
if c.context == nil {
c.context = make(log.Context)
}
for _, contexter := range contexters {
c.context.Merge(contexter.Context())
}
return &c
}
func (e errHTTP) Fields(context log.Context) *errHTTP {
c := e.clone()
if c.context == nil {
c.context = make(log.Context)
}
c.context.Merge(context)
return &c
}
func (e errHTTP) clone() errHTTP {
context := make(log.Context)
for k, v := range e.context {
context[k] = v
}
return errHTTP{
Code: e.Code,
HTTPCode: e.HTTPCode,
Message: e.Message,
Link: e.Link,
context: context,
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
return &errHTTP{
Code: err.Code,
HTTPCode: err.HTTPCode,
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
Link: err.Link,
}
}
var (
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil}
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil}
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil}
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil}
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil}
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", ""}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", ""}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
)

View File

@ -67,7 +67,6 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
}
c.mu.Lock()
c.totalSizeCurrent += size
mset(metricAttachmentsTotalSize, c.totalSizeCurrent)
c.mu.Unlock()
return size, nil
}
@ -90,7 +89,6 @@ func (c *fileCache) Remove(ids ...string) error {
c.mu.Lock()
c.totalSizeCurrent = size
c.mu.Unlock()
mset(metricAttachmentsTotalSize, size)
return nil
}

View File

@ -20,7 +20,6 @@ const (
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagTwilio = "twilio"
tagFileCache = "file_cache"
tagMessageCache = "message_cache"
tagStripe = "stripe"
@ -31,11 +30,6 @@ const (
tagMatrix = "matrix"
)
var (
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
)
// logr creates a new log event with HTTP request fields
func logr(r *http.Request) *log.Event {
return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten

1
server/mailer_emoji.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@ import (
var (
errUnexpectedMessageType = errors.New("unexpected message type")
errMessageNotFound = errors.New("message not found")
errNoRows = errors.New("no rows found")
)
// Messages cache
@ -55,11 +54,6 @@ const (
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INT
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
COMMIT;
`
insertMessageQuery = `
@ -114,14 +108,11 @@ const (
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
)
// Schema management queries
const (
currentSchemaVersion = 11
currentSchemaVersion = 10
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@ -231,30 +222,20 @@ const (
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
`
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
// 10 -> 11
migrate10To11AlterMessagesTableQuery = `
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INT
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
`
)
var (
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
0: migrateFrom0,
1: migrateFrom1,
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
6: migrateFrom6,
7: migrateFrom7,
8: migrateFrom8,
9: migrateFrom9,
10: migrateFrom10,
0: migrateFrom0,
1: migrateFrom1,
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
6: migrateFrom6,
7: migrateFrom7,
8: migrateFrom8,
9: migrateFrom9,
}
)
@ -555,7 +536,7 @@ func (c *messageCache) ExpireMessages(topics ...string) error {
}
defer tx.Rollback()
for _, t := range topics {
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil {
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix(), t); err != nil {
return err
}
}
@ -725,26 +706,6 @@ func readMessage(rows *sql.Rows) (*message, error) {
}, nil
}
func (c *messageCache) UpdateStats(messages int64) error {
_, err := c.db.Exec(updateStatsQuery, messages)
return err
}
func (c *messageCache) Stats() (messages int64, err error) {
rows, err := c.db.Query(selectStatsQuery)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
if err := rows.Scan(&messages); err != nil {
return 0, err
}
return messages, nil
}
func (c *messageCache) Close() error {
return c.db.Close()
}
@ -928,19 +889,3 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
}
return tx.Commit()
}
func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 11); err != nil {
return err
}
return tx.Commit()
}

File diff suppressed because it is too large Load Diff

View File

@ -117,19 +117,18 @@
# attachment-expiry-duration: "3h"
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
# messages will additionally be sent out as e-mail using an external SMTP server.
#
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
# below (visitor-email-limit-burst & visitor-email-limit-burst).
#
# - smtp-sender-addr is the hostname:port of the SMTP server
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
# - smtp-sender-from is the e-mail address of the sender
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
#
# smtp-sender-addr:
# smtp-sender-from:
# smtp-sender-user:
# smtp-sender-pass:
# smtp-sender-from:
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
# emails to a topic e-mail address to publish messages to a topic.
@ -144,18 +143,6 @@
# smtp-server-domain:
# smtp-server-addr-prefix:
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
#
# - 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
#
# twilio-account:
# twilio-auth-token:
# twilio-phone-number:
# twilio-verify-service:
# Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity.
#
@ -179,13 +166,11 @@
#
# disallowed-topics:
# Defines the root path of the web app, or disables the web app entirely.
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
# web app. If you self-host, you don't want to change this.
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
#
# Can be any simple path, e.g. "/", "/app", or "/ntfy". For backwards-compatibility reasons,
# the values "app" (maps to "/"), "home" (maps to "/app"), or "disable" (maps to "") to disable
# the web app entirely.
#
# web-root: /
# web-root: app
# Various feature flags used to control the web app, and API access, mainly around user and
# account management.
@ -208,12 +193,7 @@
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
#
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
# if you exceed the upstream rate limits, or the uptream server requires authentication.
#
# upstream-base-url:
# upstream-access-token:
# Rate limiting: Total number of topics before the server rejects new topics.
#
@ -254,54 +234,15 @@
# visitor-attachment-total-size-limit: "100M"
# visitor-attachment-daily-bandwidth-limit: "500M"
# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)
#
# 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: <topic1>,<topic2>,..." 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: 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 a HTTP 507 response if
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
#
# visitor-subscriber-rate-limiting: false
# Payments integration via Stripe
#
# - 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 https://dashboard.stripe.com/apikeys.
# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.
# Webhooks are essential up keep the local database in sync with the payment provider. See 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.
#
# stripe-secret-key:
# stripe-webhook-key:
# billing-contact:
# Metrics
#
# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port.
# 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"
#
# enable-metrics: false
# metrics-listen-http:
# Profiling
#
# ntfy can expose Go's 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://<ip>:<port>/debug/pprof/.
# This can be helpful to expose bottlenecks, and visualize call flows. See https://pkg.go.dev/net/http/pprof for details.
#
# profile-listen-http:
# Logging options
#

View File

@ -56,7 +56,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
Messages: limits.MessageLimit,
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
Emails: limits.EmailLimit,
Calls: limits.CallLimit,
Reservations: limits.ReservationsLimit,
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
AttachmentFileSize: limits.AttachmentFileSizeLimit,
@ -68,8 +67,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
MessagesRemaining: stats.MessagesRemaining,
Emails: stats.Emails,
EmailsRemaining: stats.EmailsRemaining,
Calls: stats.Calls,
CallsRemaining: stats.CallsRemaining,
Reservations: stats.Reservations,
ReservationsRemaining: stats.ReservationsRemaining,
AttachmentTotalSize: stats.AttachmentTotalSize,
@ -103,24 +100,21 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
Customer: true,
Subscription: u.Billing.StripeSubscriptionID != "",
Status: string(u.Billing.StripeSubscriptionStatus),
Interval: string(u.Billing.StripeSubscriptionInterval),
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
}
}
if s.config.EnableReservations {
reservations, err := s.userManager.Reservations(u.Name)
if err != nil {
return err
}
if len(reservations) > 0 {
response.Reservations = make([]*apiAccountReservation, 0)
for _, r := range reservations {
response.Reservations = append(response.Reservations, &apiAccountReservation{
Topic: r.Topic,
Everyone: r.Everyone.String(),
})
}
reservations, err := s.userManager.Reservations(u.Name)
if err != nil {
return err
}
if len(reservations) > 0 {
response.Reservations = make([]*apiAccountReservation, 0)
for _, r := range reservations {
response.Reservations = append(response.Reservations, &apiAccountReservation{
Topic: r.Topic,
Everyone: r.Everyone.String(),
})
}
}
tokens, err := s.userManager.Tokens(u.ID)
@ -143,15 +137,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
})
}
}
if s.config.TwilioAccount != "" {
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return err
}
if len(phoneNumbers) > 0 {
response.PhoneNumbers = phoneNumbers
}
}
} else {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
@ -458,7 +443,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
if err != nil {
return err
}
t.CancelSubscribersExceptUser(u.ID)
t.CancelSubscribers(u.ID)
return s.writeJSON(w, newSuccessResponse())
}
@ -521,76 +506,9 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
if err := s.messageCache.ExpireMessages(topics...); err != nil {
return err
}
go s.pruneMessages()
return nil
}
func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
} else if req.Channel != "sms" && req.Channel != "call" {
return errHTTPBadRequestPhoneNumberVerifyChannelInvalid
}
// Check user is allowed to add phone numbers
if u == nil || (u.IsUser() && u.Tier == nil) {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier.CallLimit == 0 {
return errHTTPUnauthorized
}
// Check if phone number exists
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return err
} else if util.Contains(phoneNumbers, req.Number) {
return errHTTPConflictPhoneNumberExists
}
// Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
return err
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number")
if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) {
go func() {

View File

@ -151,8 +151,6 @@ func TestAccount_Get_Anonymous(t *testing.T) {
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
require.Equal(t, int64(0), account.Stats.Emails)
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
require.Equal(t, int64(0), account.Stats.Calls)
require.Equal(t, int64(0), account.Stats.CallsRemaining)
rr = request(t, s, "POST", "/mytopic", "", nil)
require.Equal(t, 200, rr.Code)
@ -292,7 +290,6 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) {
}
func TestAccount_ExtendToken(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
@ -500,8 +497,6 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true
conf.EnableReservations = true
conf.TwilioAccount = "dummy"
s := newTestServer(t, conf)
// Create user
@ -514,7 +509,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
MessageLimit: 123,
MessageExpiryDuration: 86400 * time.Second,
EmailLimit: 32,
CallLimit: 10,
ReservationLimit: 2,
AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123,
@ -556,7 +550,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
require.Equal(t, int64(123), account.Limits.Messages)
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
require.Equal(t, int64(32), account.Limits.Emails)
require.Equal(t, int64(10), account.Limits.Calls)
require.Equal(t, int64(2), account.Limits.Reservations)
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
@ -618,7 +611,6 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
}
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
t.Parallel()
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
s := newTestServer(t, conf)
@ -663,17 +655,6 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
m2 := toMessage(t, rr.Body.String())
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
// Pre-verify message count and file
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(ms))
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(ms))
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
// Delete reservation
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
"X-Delete-Messages": "true",
@ -689,13 +670,9 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
// Verify that messages and attachments were deleted
// This does not explicitly call the manager!
waitFor(t, func() bool {
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
require.Nil(t, err)
return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))
})
time.Sleep(time.Second)
ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false)
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 0, len(ms))
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
@ -707,10 +684,91 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
}
/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond
conf.EnableSignup = true
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create user with tier
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 20,
ReservationLimit: 2,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Subscribe anonymously
anonCh, userCh := make(chan bool), make(chan bool)
go func() {
rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed!
require.Equal(t, 200, rr.Code)
messages := toMessages(t, rr.Body.String())
require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message!
require.Equal(t, "open", messages[0].Event)
require.Equal(t, "message before reservation", messages[1].Message)
anonCh <- true
log.Info("Anonymous subscription ended")
}()
// Subscribe with user
go func() {
rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks!
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
messages := toMessages(t, rr.Body.String())
require.Equal(t, 3, len(messages))
require.Equal(t, "open", messages[0].Event)
require.Equal(t, "message before reservation", messages[1].Message)
require.Equal(t, "message after reservation", messages[2].Message)
userCh <- true
log.Info("User subscription ended")
}()
// Publish message (before reservation)
time.Sleep(2 * time.Second) // Wait for subscribers
rr = request(t, s, "POST", "/mytopic", "message before reservation", nil)
require.Equal(t, 200, rr.Code)
time.Sleep(2 * time.Second) // Wait for subscribers to receive message
// Reserve a topic
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
// Everyone but phil should be killed
select {
case <-anonCh:
case <-time.After(5 * time.Second):
t.Fatal("Waiting for anonymous subscription to be killed failed")
}
// Publish a message
rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
// Kill user Go routine
s.topics["mytopic"].CancelSubscribers("<invalid>")
select {
case <-userCh:
case <-time.After(5 * time.Second):
t.Fatal("Waiting for user subscription to be killed failed")
}
}
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
s := newTestServer(t, conf)
defer s.closeDatabases()
@ -732,12 +790,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
})
require.Equal(t, 200, rr.Code)
// Wait for stats queue writer, verify that message stats were persisted
waitFor(t, func() bool {
u, err := s.userManager.User("phil")
require.Nil(t, err)
return int64(1) == u.Stats.Messages
})
// Wait for stats queue writer
time.Sleep(300 * time.Millisecond)
// Verify that message stats were persisted
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(1), u.Stats.Messages)
// Change tier, make a request (to reset limiters)
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@ -755,11 +814,10 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.Equal(t, 200, rr.Code)
// Verify that message stats were persisted
waitFor(t, func() bool {
u, err := s.userManager.User("phil")
require.Nil(t, err)
return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!
})
time.Sleep(300 * time.Millisecond)
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
// Stats keep counting
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
@ -768,4 +826,5 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.Equal(t, 200, rr.Code)
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
}*/
}

View File

@ -1,143 +0,0 @@
package server
import (
"heckel.io/ntfy/user"
"net/http"
)
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
users, err := s.userManager.Users()
if err != nil {
return err
}
grants, err := s.userManager.AllGrants()
if err != nil {
return err
}
usersResponse := make([]*apiUserResponse, len(users))
for i, u := range users {
tier := ""
if u.Tier != nil {
tier = u.Tier.Code
}
userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
for i, g := range grants[u.ID] {
userGrants[i] = &apiUserGrantResponse{
Topic: g.TopicPattern,
Permission: g.Allow.String(),
}
}
usersResponse[i] = &apiUserResponse{
Username: u.Name,
Role: string(u.Role),
Tier: tier,
Grants: userGrants,
}
}
return s.writeJSON(w, usersResponse)
}
func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !user.AllowedUsername(req.Username) || req.Password == "" {
return errHTTPBadRequest.Wrap("username invalid, or password missing")
}
u, err := s.userManager.User(req.Username)
if err != nil && err != user.ErrUserNotFound {
return err
} else if u != nil {
return errHTTPConflictUserExists
}
var tier *user.Tier
if req.Tier != "" {
tier, err = s.userManager.Tier(req.Tier)
if err == user.ErrTierNotFound {
return errHTTPBadRequestTierInvalid
} else if err != nil {
return err
}
}
if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil {
return err
}
if tier != nil {
if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
return err
}
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
u, err := s.userManager.User(req.Username)
if err == user.ErrUserNotFound {
return errHTTPBadRequestUserNotFound
} else if err != nil {
return err
} else if !u.IsUser() {
return errHTTPUnauthorized.Wrap("can only remove regular users from API")
}
if err := s.userManager.RemoveUser(req.Username); err != nil {
return err
}
if err := s.killUserSubscriber(u, "*"); err != nil { // FIXME super inefficient
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
_, err = s.userManager.User(req.Username)
if err == user.ErrUserNotFound {
return errHTTPBadRequestUserNotFound
} else if err != nil {
return err
}
permission, err := user.ParsePermission(req.Permission)
if err != nil {
return errHTTPBadRequestPermissionInvalid
}
if err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
u, err := s.userManager.User(req.Username)
if err != nil {
return err
}
if err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil {
return err
}
if err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) killUserSubscriber(u *user.User, topicPattern string) error {
topics, err := s.topicsFromPattern(topicPattern)
if err != nil {
return err
}
for _, t := range topics {
t.CancelSubscriberUser(u.ID)
}
return nil
}

View File

@ -1,181 +0,0 @@
package server
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"sync/atomic"
"testing"
"time"
)
func TestUser_AddRemove(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
// Create user via API
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Create user with tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 4, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Nil(t, users[1].Tier)
require.Equal(t, "emma", users[2].Name)
require.Equal(t, user.RoleUser, users[2].Role)
require.Equal(t, "tier1", users[2].Tier.Code)
require.Equal(t, user.Everyone, users[3].Name)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
}
func TestUser_AddRemove_Failures(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
// Cannot create user with invalid username
rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
// Cannot create user if user already exists
rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
// Cannot create user with invalid tier
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
// Cannot delete user as non-admin
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
}
func TestAccess_AllowReset(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
// Subscribing not allowed
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
// Grant access
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Now subscribing is allowed
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Reset access
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Subscribing not allowed (again)
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
}
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
// Grant access fails, because non-admin
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
}
func TestAccess_AllowReset_KillConnection(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin, grant access to "gol*" topics
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
start, timeTaken := time.Now(), atomic.Int64{}
go func() {
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
timeTaken.Store(time.Since(start).Milliseconds())
}()
time.Sleep(500 * time.Millisecond)
// Reset access
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Wait for connection to be killed; this will fail if the connection is never killed
waitFor(t, func() bool {
return timeTaken.Load() >= 500
})
}

View File

@ -2,7 +2,6 @@ package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"strings"
)
@ -35,20 +34,16 @@ func (s *Server) execManager() {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.topics {
subs, lastAccess := t.Stats()
ev := log.Tag(tagManager).With(t)
if t.Stale() {
if ev.IsTrace() {
ev.Trace("- topic %s: Deleting stale topic (%d subscribers, accessed %s)", t.ID, subs, util.FormatTime(lastAccess))
}
subs := t.SubscribersCount()
log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs)
msgs, exists := messageCounts[t.ID]
if subs == 0 && (!exists || msgs == 0) {
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
emptyTopics++
delete(s.topics, t.ID)
} else {
if ev.IsTrace() {
ev.Trace("- topic %s: %d subscribers, accessed %s", t.ID, subs, util.FormatTime(lastAccess))
}
subscribers += subs
continue
}
subscribers += subs
}
}).
Debug("Removed %d empty topic(s)", emptyTopics)
@ -63,24 +58,10 @@ func (s *Server) execManager() {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
}
// Users
var usersCount int64
if s.userManager != nil {
usersCount, err = s.userManager.UsersCount()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error counting users")
}
}
// Print stats
s.mu.RLock()
s.mu.Lock()
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
s.mu.RUnlock()
// Update stats
s.updateAndWriteStats(messagesCount)
// Log stats
s.mu.Unlock()
log.
Tag(tagManager).
Fields(log.Context{
@ -89,7 +70,6 @@ func (s *Server) execManager() {
"topics_active": topicsCount,
"subscribers": subscribers,
"visitors": visitorsCount,
"users": usersCount,
"emails_received": receivedMailTotal,
"emails_received_success": receivedMailSuccess,
"emails_received_failure": receivedMailFailure,
@ -98,11 +78,6 @@ func (s *Server) execManager() {
"emails_sent_failure": sentMailFailure,
}).
Info("Server stats")
mset(metricMessagesCached, messagesCached)
mset(metricVisitors, visitorsCount)
mset(metricUsers, usersCount)
mset(metricSubscribers, subscribers)
mset(metricTopics, topicsCount)
}
func (s *Server) pruneVisitors() {
@ -141,30 +116,29 @@ func (s *Server) pruneTokens() {
}
func (s *Server) pruneAttachments() {
if s.fileCache == nil {
return
if s.fileCache != nil {
log.
Tag(tagManager).
Timing(func() {
ids, err := s.messageCache.AttachmentsExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
} else if len(ids) > 0 {
if log.Tag(tagManager).IsDebug() {
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.fileCache.Remove(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
}
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired attachments to delete")
}
}).
Debug("Deleted expired attachments")
}
log.
Tag(tagManager).
Timing(func() {
ids, err := s.messageCache.AttachmentsExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
} else if len(ids) > 0 {
if log.Tag(tagManager).IsDebug() {
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.fileCache.Remove(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
}
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired attachments to delete")
}
}).
Debug("Deleted expired attachments")
}
func (s *Server) pruneMessages() {
@ -175,10 +149,8 @@ func (s *Server) pruneMessages() {
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
} else if len(expiredMessageIDs) > 0 {
if s.fileCache != nil {
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
}
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
}
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")

View File

@ -1,28 +0,0 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {
// Tests that the manager runs without attachment-cache-dir set, see #617
c := newTestConfig(t)
c.AttachmentCacheDir = ""
s := newTestServer(t, c)
// Publish a message
rr := request(t, s, "POST", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
m := toMessage(t, rr.Body.String())
// Expire message
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
// Does not panic
s.pruneMessages()
// Actually deleted
_, err := s.messageCache.Message(m.ID)
require.Equal(t, errMessageNotFound, err)
}

View File

@ -8,7 +8,6 @@ import (
"io"
"net/http"
"strings"
"time"
)
// Matrix Push Gateway / UnifiedPush / ntfy integration:
@ -72,27 +71,25 @@ type matrixResponse struct {
Rejected []string `json:"rejected"`
}
// errMatrix represents an error when handing Matrix gateway messages
type errMatrix struct {
pushKey string
err error
}
func (e errMatrix) Error() string {
if e.err != nil {
return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error())
}
return fmt.Sprintf("message with push key %s rejected", e.pushKey)
}
const (
// matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response
// will return an HTTP 200 with the push key (i.e. "rejected":["<pushkey>"]}), if no rate visitor has been set on
// the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending
// messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/
matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour
// matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest)
// along with the request. The push key is only used if an error occurs down the line.
matrixPushKeyHeader = "X-Matrix-Pushkey"
)
// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages
//
// If the push key is set, the app server will remove it and will never send messages using the same
// push key again, until the user repairs it.
type errMatrixPushkeyRejected struct {
rejectedPushKey string
configuredBaseURL string
}
func (e errMatrixPushkeyRejected) Error() string {
return fmt.Sprintf("push key must be prefixed with base URL, received push key: %s, configured base URL: %s", e.rejectedPushKey, e.configuredBaseURL)
}
// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new
// HTTP request that looks like a normal ntfy request from it.
//
@ -125,19 +122,17 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
}
pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316
if !strings.HasPrefix(pushKey, baseURL+"/") {
return nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL}
return nil, &errMatrix{pushKey: pushKey, err: wrapErrHTTP(errHTTPBadRequestMatrixPushkeyBaseURLMismatch, "received push key: %s, configured base URL: %s", pushKey, baseURL)}
}
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
if err != nil {
return nil, err
return nil, &errMatrix{pushKey: pushKey, err: err}
}
newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted
if r.Header.Get("X-Forwarded-For") != "" {
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
}
newRequest = withContext(newRequest, map[contextKey]any{
contextMatrixPushKey: pushKey,
})
newRequest.Header.Set(matrixPushKeyHeader, pushKey)
return newRequest, nil
}
@ -149,6 +144,12 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
return err
}
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error {
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Matrix gateway error")
return writeMatrixResponse(w, err.pushKey)
}
// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter
func writeMatrixSuccess(w http.ResponseWriter) error {
return writeMatrixResponse(w, "")

View File

@ -3,6 +3,7 @@ package server
import (
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
@ -18,6 +19,7 @@ func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
require.Nil(t, err)
require.Equal(t, "POST", newRequest.Method)
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String())
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey"))
require.Equal(t, body, readAll(t, newRequest.Body))
}
@ -54,10 +56,10 @@ func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
matrixErr, ok := err.(*errMatrixPushkeyRejected)
matrixErr, ok := err.(*errMatrix)
require.True(t, ok)
require.Equal(t, "push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.Error())
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.rejectedPushKey)
require.Equal(t, "invalid request: push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.err.Error())
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey)
}
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
@ -69,7 +71,9 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
func TestMatrix_WriteMatrixError(t *testing.T) {
w := httptest.NewRecorder()
require.Nil(t, writeMatrixResponse(w, "https://ntfy.example.com/upABCDEFGHI?up=1"))
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
v := newVisitor(newTestConfig(t), nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
require.Equal(t, 200, w.Result().StatusCode)
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
}

View File

@ -1,132 +0,0 @@
package server
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
metricMessagesPublishedSuccess prometheus.Counter
metricMessagesPublishedFailure prometheus.Counter
metricMessagesCached prometheus.Gauge
metricMessagePublishDurationMillis prometheus.Gauge
metricFirebasePublishedSuccess prometheus.Counter
metricFirebasePublishedFailure prometheus.Counter
metricEmailsPublishedSuccess prometheus.Counter
metricEmailsPublishedFailure prometheus.Counter
metricEmailsReceivedSuccess prometheus.Counter
metricEmailsReceivedFailure prometheus.Counter
metricCallsMadeSuccess prometheus.Counter
metricCallsMadeFailure prometheus.Counter
metricUnifiedPushPublishedSuccess prometheus.Counter
metricMatrixPublishedSuccess prometheus.Counter
metricMatrixPublishedFailure prometheus.Counter
metricAttachmentsTotalSize prometheus.Gauge
metricVisitors prometheus.Gauge
metricSubscribers prometheus.Gauge
metricTopics prometheus.Gauge
metricUsers prometheus.Gauge
metricHTTPRequests *prometheus.CounterVec
)
func initMetrics() {
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_success",
})
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_failure",
})
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_messages_cached_total",
})
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_message_publish_duration_ms",
})
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_success",
})
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_failure",
})
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_success",
})
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_failure",
})
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_success",
})
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure",
})
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success",
})
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_success",
})
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_failure",
})
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_attachments_total_size",
})
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_visitors_total",
})
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_users_total",
})
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_subscribers_total",
})
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_topics_total",
})
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
prometheus.MustRegister(
metricMessagesPublishedSuccess,
metricMessagesPublishedFailure,
metricMessagesCached,
metricMessagePublishDurationMillis,
metricFirebasePublishedSuccess,
metricFirebasePublishedFailure,
metricEmailsPublishedSuccess,
metricEmailsPublishedFailure,
metricEmailsReceivedSuccess,
metricEmailsReceivedFailure,
metricCallsMadeSuccess,
metricCallsMadeFailure,
metricUnifiedPushPublishedSuccess,
metricMatrixPublishedSuccess,
metricMatrixPublishedFailure,
metricAttachmentsTotalSize,
metricVisitors,
metricUsers,
metricSubscribers,
metricTopics,
metricHTTPRequests,
)
}
// minc increments a prometheus.Counter if it is non-nil
func minc(counter prometheus.Counter) {
if counter != nil {
counter.Inc()
}
}
// mset sets a prometheus.Gauge if it is non-nil
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
if gauge != nil {
gauge.Set(float64(value))
}
}

View File

@ -1,17 +1,8 @@
package server
import (
"net/http"
"heckel.io/ntfy/util"
)
type contextKey int
const (
contextRateVisitor contextKey = iota + 2586
contextTopic
contextMatrixPushKey
"net/http"
)
func (s *Server) limitRequests(next handleFunc) handleFunc {
@ -25,33 +16,9 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
}
}
// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
t, err := s.topicFromPath(r.URL.Path)
if err != nil {
return err
}
vrate := v
if rateVisitor := t.RateVisitor(); rateVisitor != nil {
vrate = rateVisitor
}
r = withContext(r, map[contextKey]any{
contextRateVisitor: vrate,
contextTopic: t,
})
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
return next(w, r, v)
} else if !vrate.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests
}
return next(w, r, v)
}
}
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.WebRoot == "" {
if !s.config.EnableWeb {
return errHTTPNotFound
}
return next(w, r, v)
@ -76,24 +43,6 @@ func (s *Server) ensureUser(next handleFunc) handleFunc {
})
}
func (s *Server) ensureAdmin(next handleFunc) handleFunc {
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if !v.User().IsAdmin() {
return errHTTPUnauthorized
}
return next(w, r, v)
})
}
func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.TwilioAccount == "" || s.userManager == nil {
return errHTTPNotFound
}
return next(w, r, v)
}
}
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.StripeSecretKey == "" || s.stripe == nil {

View File

@ -68,7 +68,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
Messages: freeTier.MessageLimit,
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
Emails: freeTier.EmailLimit,
Calls: freeTier.CallLimit,
Reservations: freeTier.ReservationsLimit,
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
@ -81,23 +80,19 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
return err
}
for _, tier := range tiers {
priceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID]
if priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices!
priceStr, ok := prices[tier.StripePriceID]
if tier.StripePriceID == "" || !ok {
continue
}
response = append(response, &apiAccountBillingTier{
Code: tier.Code,
Name: tier.Name,
Prices: &apiAccountBillingPrices{
Month: priceMonth,
Year: priceYear,
},
Code: tier.Code,
Name: tier.Name,
Price: priceStr,
Limits: &apiAccountLimits{
Basis: string(visitorLimitBasisTier),
Messages: tier.MessageLimit,
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
Emails: tier.EmailLimit,
Calls: tier.CallLimit,
Reservations: tier.ReservationLimit,
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
AttachmentFileSize: tier.AttachmentFileSizeLimit,
@ -122,21 +117,11 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
tier, err := s.userManager.Tier(req.Tier)
if err != nil {
return err
}
var priceID string
if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" {
priceID = tier.StripeMonthlyPriceID
} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" {
priceID = tier.StripeYearlyPriceID
} else {
} else if tier.StripePriceID == "" {
return errNotAPaidTier
}
logvr(v, r).
With(tier).
Fields(log.Context{
"stripe_price_id": priceID,
"stripe_subscription_interval": req.Interval,
}).
Tag(tagStripe).
Info("Creating Stripe checkout flow")
var stripeCustomerID *string
@ -158,7 +143,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
AllowPromotionCodes: stripe.Bool(true),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Price: stripe.String(tier.StripePriceID),
Quantity: stripe.Int64(1),
},
},
@ -190,16 +175,15 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
if err != nil {
return err
} else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" {
return errHTTPBadRequestBillingRequestInvalid.Wrap("customer or subscription not found")
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "customer or subscription not found")
}
sub, err := s.stripe.GetSubscription(sess.Subscription.ID)
if err != nil {
return err
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil {
return errHTTPBadRequestBillingRequestInvalid.Wrap("more than one line item in existing subscription")
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil {
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription")
}
priceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval
tier, err := s.userManager.TierByStripePrice(priceID)
tier, err := s.userManager.TierByStripePrice(sub.Items.Data[0].Price.ID)
if err != nil {
return err
}
@ -213,10 +197,8 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
Tag(tagStripe).
Fields(log.Context{
"stripe_customer_id": sess.Customer.ID,
"stripe_price_id": priceID,
"stripe_subscription_id": sub.ID,
"stripe_subscription_status": string(sub.Status),
"stripe_subscription_interval": string(interval),
"stripe_subscription_paid_until": sub.CurrentPeriodEnd,
}).
Info("Stripe checkout flow succeeded, updating user tier and subscription")
@ -231,7 +213,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil {
return err
}
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
return err
}
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
@ -253,37 +235,28 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
if err != nil {
return err
}
var priceID string
if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" {
priceID = tier.StripeMonthlyPriceID
} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" {
priceID = tier.StripeYearlyPriceID
} else {
return errNotAPaidTier
}
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"new_tier_id": tier.ID,
"new_tier_code": tier.Code,
"new_tier_stripe_price_id": priceID,
"new_tier_stripe_subscription_interval": req.Interval,
"new_tier_id": tier.ID,
"new_tier_name": tier.Name,
"new_tier_stripe_price_id": tier.StripePriceID,
// Other stripe_* fields filled by visitor context
}).
Info("Changing Stripe subscription and billing tier to %s/%s (price %s, %s)", tier.ID, tier.Name, priceID, req.Interval)
Info("Changing Stripe subscription and billing tier to %s/%s (price %s)", tier.ID, tier.Name, tier.StripePriceID)
sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID)
if err != nil {
return err
} else if sub.Items == nil || len(sub.Items.Data) != 1 {
return errHTTPBadRequestBillingRequestInvalid.Wrap("no items, or more than one item")
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "no items, or more than one item")
}
params := &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(false),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
Items: []*stripe.SubscriptionItemsParams{
{
ID: stripe.String(sub.Items.Data[0].ID),
Price: stripe.String(priceID),
Price: stripe.String(tier.StripePriceID),
},
},
}
@ -372,22 +345,20 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request,
ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
if err != nil {
return err
} else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" || ev.Items.Data[0].Price.Recurring == nil {
logvr(v, r).Tag(tagStripe).Field("stripe_request", fmt.Sprintf("%#v", ev)).Warn("Unexpected request from Stripe")
} else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" {
return errHTTPBadRequestBillingRequestInvalid
}
subscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval
subscriptionID, priceID := ev.ID, ev.Items.Data[0].Price.ID
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"stripe_webhook_type": event.Type,
"stripe_customer_id": ev.Customer,
"stripe_price_id": priceID,
"stripe_subscription_id": ev.ID,
"stripe_subscription_status": ev.Status,
"stripe_subscription_interval": interval,
"stripe_subscription_paid_until": ev.CurrentPeriodEnd,
"stripe_subscription_cancel_at": ev.CancelAt,
"stripe_price_id": priceID,
}).
Info("Updating subscription to status %s, with price %s", ev.Status, priceID)
userFn := func() (*user.User, error) {
@ -405,7 +376,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request,
if err != nil {
return err
}
if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
return err
}
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
@ -428,14 +399,14 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request,
Tag(tagStripe).
Field("stripe_webhook_type", event.Type).
Info("Subscription deleted, downgrading to unpaid tier")
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", "", 0, 0); err != nil {
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", 0, 0); err != nil {
return err
}
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
return nil
}
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error {
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
reservationsLimit := visitorDefaultReservationsLimit
if tier != nil {
reservationsLimit = tier.ReservationLimit
@ -452,8 +423,9 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"new_tier_id": tier.ID,
"new_tier_code": tier.Code,
"new_tier_id": tier.ID,
"new_tier_name": tier.Name,
"new_tier_stripe_price_id": tier.StripePriceID,
}).
Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name)
if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {
@ -465,7 +437,6 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
StripeCustomerID: customerID,
StripeSubscriptionID: subscriptionID,
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
}
@ -477,16 +448,20 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
// fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices
// in memory, and ultimately for the web app to display the price table.
func (s *Server) fetchStripePrices() (map[string]int64, error) {
func (s *Server) fetchStripePrices() (map[string]string, error) {
log.Debug("Caching prices from Stripe API")
priceMap := make(map[string]int64)
priceMap := make(map[string]string)
prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)})
if err != nil {
log.Warn("Fetching Stripe prices failed: %s", err.Error())
return nil, err
}
for _, p := range prices {
priceMap[p.ID] = p.UnitAmount
if p.UnitAmount%100 == 0 {
priceMap[p.ID] = fmt.Sprintf("$%d", p.UnitAmount/100)
} else {
priceMap[p.ID] = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100)
}
log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID])
}
return priceMap, nil

View File

@ -37,9 +37,7 @@ func TestPayments_Tiers(t *testing.T) {
On("ListPrices", mock.Anything).
Return([]*stripe.Price{
{ID: "price_123", UnitAmount: 500},
{ID: "price_124", UnitAmount: 5000},
{ID: "price_456", UnitAmount: 1000},
{ID: "price_457", UnitAmount: 10000},
{ID: "price_999", UnitAmount: 9999},
}, nil)
@ -60,8 +58,7 @@ func TestPayments_Tiers(t *testing.T) {
AttachmentFileSizeLimit: 999,
AttachmentTotalSizeLimit: 888,
AttachmentExpiryDuration: time.Minute,
StripeMonthlyPriceID: "price_123",
StripeYearlyPriceID: "price_124",
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_444",
@ -74,8 +71,7 @@ func TestPayments_Tiers(t *testing.T) {
AttachmentFileSizeLimit: 999111,
AttachmentTotalSizeLimit: 888111,
AttachmentExpiryDuration: time.Hour,
StripeMonthlyPriceID: "price_456",
StripeYearlyPriceID: "price_457",
StripePriceID: "price_456",
}))
response := request(t, s, "GET", "/v1/tiers", "", nil)
require.Equal(t, 200, response.Code)
@ -102,8 +98,6 @@ func TestPayments_Tiers(t *testing.T) {
require.Equal(t, "pro", tier.Code)
require.Equal(t, "Pro", tier.Name)
require.Equal(t, "tier", tier.Limits.Basis)
require.Equal(t, int64(500), tier.Prices.Month)
require.Equal(t, int64(5000), tier.Prices.Year)
require.Equal(t, int64(777), tier.Limits.Reservations)
require.Equal(t, int64(1000), tier.Limits.Messages)
require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration)
@ -115,8 +109,6 @@ func TestPayments_Tiers(t *testing.T) {
tier = tiers[2]
require.Equal(t, "business", tier.Code)
require.Equal(t, "Business", tier.Name)
require.Equal(t, int64(1000), tier.Prices.Month)
require.Equal(t, int64(10000), tier.Prices.Year)
require.Equal(t, "tier", tier.Limits.Basis)
require.Equal(t, int64(777333), tier.Limits.Reservations)
require.Equal(t, int64(2000), tier.Limits.Messages)
@ -144,14 +136,14 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
// Create subscription
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
@ -180,9 +172,9 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
@ -195,7 +187,7 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
// Create subscription
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
@ -222,9 +214,9 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
@ -275,7 +267,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "starter",
StripeMonthlyPriceID: "price_1234",
StripePriceID: "price_1234",
ReservationLimit: 1,
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
MessageExpiryDuration: time.Hour,
@ -306,12 +298,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
Items: &stripe.SubscriptionItemList{
Data: []*stripe.SubscriptionItem{
{
Price: &stripe.Price{
ID: "price_1234",
Recurring: &stripe.PriceRecurring{
Interval: stripe.PriceRecurringIntervalMonth,
},
},
Price: &stripe.Price{ID: "price_1234"},
},
},
},
@ -346,7 +333,6 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Equal(t, "", u.Billing.StripeCustomerID)
require.Equal(t, "", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
@ -363,7 +349,6 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages)
@ -415,8 +400,6 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
}
func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {
t.Parallel()
// This tests incoming webhooks from Stripe to update a subscription:
// - All Stripe columns are updated in the user table
// - When downgrading, excess reservations are deleted, including messages and attachments in
@ -440,7 +423,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "starter",
StripeMonthlyPriceID: "price_1234", // !
StripePriceID: "price_1234", // !
ReservationLimit: 1, // !
MessageLimit: 100,
MessageExpiryDuration: time.Hour,
@ -452,7 +435,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_2",
Code: "pro",
StripeMonthlyPriceID: "price_1111", // !
StripePriceID: "price_1111", // !
ReservationLimit: 3, // !
MessageLimit: 200,
MessageExpiryDuration: time.Hour,
@ -474,7 +457,6 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(456, 0),
}
@ -517,10 +499,9 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
// Verify that reservations were deleted
r, err := s.userManager.Reservations("phil")
@ -565,10 +546,10 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
// Create a user with a Stripe subscription and 3 reservations
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "pro",
StripeMonthlyPriceID: "price_1234",
ReservationLimit: 1,
ID: "ti_1",
Code: "pro",
StripePriceID: "price_1234",
ReservationLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@ -581,7 +562,6 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(0, 0),
}))
@ -635,11 +615,11 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
stripeMock.
On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(false),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
Items: []*stripe.SubscriptionItemsParams{
{
ID: stripe.String("someid_123"),
Price: stripe.String("price_457"),
Price: stripe.String("price_456"),
},
},
}).
@ -647,16 +627,14 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripeMonthlyPriceID: "price_123",
StripeYearlyPriceID: "price_124",
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_456",
Code: "business",
StripeMonthlyPriceID: "price_456",
StripeYearlyPriceID: "price_457",
ID: "ti_456",
Code: "business",
StripePriceID: "price_456",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@ -666,7 +644,7 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
}))
// Call endpoint to change subscription
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
@ -817,10 +795,7 @@ const subscriptionUpdatedEventJSON = `
"data": [
{
"price": {
"id": "price_1234",
"recurring": {
"interval": "year"
}
"id": "price_1234"
}
}
]
@ -843,10 +818,7 @@ const subscriptionDeletedEventJSON = `
"data": [
{
"price": {
"id": "price_1234",
"recurring": {
"interval": "month"
}
"id": "price_1234"
}
}
]

File diff suppressed because it is too large Load Diff

View File

@ -1,176 +0,0 @@
package server
import (
"bytes"
"encoding/xml"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/url"
"strings"
)
const (
twilioCallFormat = `
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic %s. Message:
<break time="1s"/>
%s
<break time="1s"/>
End of message.
<break time="1s"/>
This message was sent by user %s. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/>
</Say>
<Say>Goodbye.</Say>
</Response>`
)
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
// If the user is anonymous, it will return an error.
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
if u == nil {
return "", errHTTPBadRequestAnonymousCallsNotAllowed
}
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(phoneNumbers) == 0 {
return "", errHTTPBadRequestPhoneNumberNotVerified
}
if toBool(phoneNumber) {
return phoneNumbers[0], nil
} else if util.Contains(phoneNumbers, phoneNumber) {
return phoneNumber, nil
}
for _, p := range phoneNumbers {
if p == phoneNumber {
return phoneNumber, nil
}
}
return "", errHTTPBadRequestPhoneNumberNotVerified
}
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
// Failures will be logged, but not returned to the caller.
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
u, sender := v.User(), m.Sender.String()
if u != nil {
sender = u.Name
}
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
data := url.Values{}
data.Set("From", s.config.TwilioPhoneNumber)
data.Set("To", to)
data.Set("Twiml", body)
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
response, err := s.callPhoneInternal(data)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
minc(metricCallsMadeFailure)
return
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
minc(metricCallsMadeSuccess)
}
func (s *Server) callPhoneInternal(data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(response), nil
}
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
}
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return errHTTPGonePhoneVerificationExpired
}
return errHTTPInternalError
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
}

View File

@ -1,264 +0,0 @@
package server
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
var called, verified atomic.Bool
var code atomic.Pointer[string]
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
if code.Load() != nil {
t.Fatal("Should be only called once")
}
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
code.Store(util.String("123456"))
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
if verified.Load() {
t.Fatal("Should be only called once")
}
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
verified.Store(true)
} else {
t.Fatal("Unexpected path:", r.URL.Path)
}
}))
defer twilioVerifyServer.Close()
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioCallsServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
c.TwilioCallsBaseURL = twilioCallsServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
c.TwilioVerifyService = "VA1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
// Send verification code for phone number
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return *code.Load() == "123456"
})
// Add phone number with code
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return verified.Load()
})
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 1, len(phoneNumbers))
require.Equal(t, "+12223334444", phoneNumbers[0])
// Do the thing
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
// Remove the phone number
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// Verify the phone number is gone from the DB
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 0, len(phoneNumbers))
}
func TestServer_Twilio_Call_Success(t *testing.T) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
}
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes", // <<<------
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
}
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "http://dummy.invalid"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+invalid",
})
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+123123",
})
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+1234",
})
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
}

View File

@ -4,15 +4,14 @@ import (
_ "embed" // required by go:embed
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
type mailer interface {
@ -37,10 +36,7 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
if err != nil {
return err
}
var auth smtp.Auth
if s.config.SMTPSenderUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
}
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{
@ -132,23 +128,31 @@ This message was sent by {ip} at {time} via {topicURL}`
}
var (
//go:embed "mailer_emoji_map.json"
//go:embed "mailer_emoji.json"
emojisJSON string
)
type emoji struct {
Emoji string `json:"emoji"`
Aliases []string `json:"aliases"`
}
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
var emojiMap map[string]string
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
var emojis []emoji
if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil {
return nil, nil, err
}
tagsOut = make([]string, 0)
emojisOut = make([]string, 0)
for _, t := range tags {
if emoji, ok := emojiMap[t]; ok {
emojisOut = append(emojisOut, emoji)
} else {
tagsOut = append(tagsOut, t)
nextTag:
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
for _, e := range emojis {
if util.Contains(e.Aliases, t) {
emojisOut = append(emojisOut, e.Emoji)
continue nextTag
}
}
tagsOut = append(tagsOut, t)
}
return
}

View File

@ -2,14 +2,12 @@ package server
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"github.com/emersion/go-smtp"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/http"
"net/http/httptest"
@ -23,14 +21,9 @@ var (
errInvalidAddress = errors.New("invalid address")
errInvalidTopic = errors.New("invalid topic")
errTooManyRecipients = errors.New("too many recipients")
errMultipartNestedTooDeep = errors.New("multipart message nested too deep")
errUnsupportedContentType = errors.New("unsupported content type")
)
const (
maxMultipartDepth = 2
)
// smtpBackend implements SMTP server methods.
type smtpBackend struct {
config *Config
@ -66,7 +59,6 @@ type smtpSession struct {
backend *smtpBackend
conn *smtp.Conn
topic string
token string
mu sync.Mutex
}
@ -83,7 +75,6 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
func (s *smtpSession) Rcpt(to string) error {
logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to)
return s.withFailCount(func() error {
token := ""
conf := s.backend.config
addressList, err := mail.ParseAddressList(to)
if err != nil {
@ -95,27 +86,18 @@ func (s *smtpSession) Rcpt(to string) error {
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
return errInvalidDomain
}
// Remove @ntfy.sh from end of email
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
if conf.SMTPServerAddrPrefix != "" {
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
return errInvalidAddress
}
// remove ntfy- from beginning of email
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
}
// If email contains token, split topic and token
if strings.Contains(to, "+") {
parts := strings.Split(to, "+")
to = parts[0]
token = parts[1]
}
if !topicRegex.MatchString(to) {
return errInvalidTopic
}
s.mu.Lock()
s.topic = to
s.token = token
s.mu.Unlock()
return nil
})
@ -138,7 +120,7 @@ func (s *smtpSession) Data(r io.Reader) error {
if err != nil {
return err
}
body, err := readMailBody(msg.Body, msg.Header)
body, err := readMailBody(msg)
if err != nil {
return err
}
@ -166,7 +148,6 @@ func (s *smtpSession) Data(r io.Reader) error {
s.backend.mu.Lock()
s.backend.success++
s.backend.mu.Unlock()
minc(metricEmailsReceivedSuccess)
return nil
})
}
@ -177,6 +158,7 @@ func (s *smtpSession) publishMessage(m *message) error {
if err != nil {
remoteAddr = s.conn.Conn().RemoteAddr().String()
}
// Call HTTP handler with fake HTTP request
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
@ -189,9 +171,6 @@ func (s *smtpSession) publishMessage(m *message) error {
if m.Title != "" {
req.Header.Set("Title", m.Title)
}
if s.token != "" {
req.Header.Add("Authorization", "Bearer "+s.token)
}
rr := httptest.NewRecorder()
s.backend.handler(rr, req)
if rr.Code != http.StatusOK {
@ -219,59 +198,52 @@ func (s *smtpSession) withFailCount(fn func() error) error {
// We do not want to spam the log with WARN messages.
logem(s.conn).Err(err).Debug("Incoming mail error")
s.backend.failure++
minc(metricEmailsReceivedFailure)
}
return err
}
func readMailBody(body io.Reader, header mail.Header) (string, error) {
if header.Get("Content-Type") == "" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
func readMailBody(msg *mail.Message) (string, error) {
if msg.Header.Get("Content-Type") == "" {
return readPlainTextMailBody(msg)
}
contentType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if strings.ToLower(contentType) == "text/plain" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
return readMultipartMailBody(body, params, 0)
if contentType == "text/plain" {
return readPlainTextMailBody(msg)
} else if strings.HasPrefix(contentType, "multipart/") {
return readMultipartMailBody(msg, params)
}
return "", errUnsupportedContentType
}
func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) {
if depth >= maxMultipartDepth {
return "", errMultipartNestedTooDeep
}
mr := multipart.NewReader(body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if strings.ToLower(partContentType) == "text/plain" {
return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
return readMultipartMailBody(part, partParams, depth+1)
}
// Continue with next part
}
}
func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
if strings.ToLower(transferEncoding) == "base64" {
reader = base64.NewDecoder(base64.StdEncoding, reader)
} else if strings.ToLower(transferEncoding) == "quoted-printable" {
reader = quotedprintable.NewReader(reader)
}
body, err := io.ReadAll(reader)
func readPlainTextMailBody(msg *mail.Message) (string, error) {
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if partContentType != "text/plain" {
continue
}
body, err := io.ReadAll(part)
if err != nil {
return "", err
}
return string(body), nil
}
}

View File

@ -303,39 +303,6 @@ BBBBBBBBBBBBBBBBBBBBBBBBB`
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Plaintext_QuotedPrintable(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more
From: Phil <phil@example.com>
To: mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
what's
=C3=A0&=C3=A9"'(-=C3=A8_=C3=A7=C3=A0)
=3D=3D=3D=3D=3D
up
.
`
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "and one more", r.Header.Get("Title"))
require.Equal(t, `what's
à&é"'(-è_çà)
=====
up`, readAll(t, r.Body))
})
conf.SMTPServerAddrPrefix = ""
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Unsupported(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
@ -381,214 +348,6 @@ what's up
writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address")
}
func TestSmtpBackend_Base64Body(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Content-Type: multipart/mixed; boundary="===============2138658284696597373=="
MIME-Version: 1.0
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
From: =?utf-8?q?Robbie?= <test@mydomain.me>
To: test@mydomain.me
Date: Thu, 16 Feb 2023 01:04:00 -0000
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
This is a multi-part message in MIME format.
--===============2138658284696597373==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
--===============2138658284696597373==
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
--===============2138658284696597373==--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title"))
require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_MultipartQuotedPrintable(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
MIME-Version: 1.0
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more
From: Phil <phil@example.com>
To: ntfy-mytopic@ntfy.sh
Content-Type: multipart/alternative; boundary="000000000000f3320b05d42915c9"
--000000000000f3320b05d42915c9
Content-Type: text/html; charset="UTF-8"
html, ignore me
--000000000000f3320b05d42915c9
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
what's
=C3=A0&=C3=A9"'(-=C3=A8_=C3=A7=C3=A0)
=3D=3D=3D=3D=3D
up
--000000000000f3320b05d42915c9--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "and one more", r.Header.Get("Title"))
require.Equal(t, `what's
à&é"'(-è_çà)
=====
up`, readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_NestedMultipartBase64(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Content-Type: multipart/mixed; boundary="===============2138658284696597373=="
MIME-Version: 1.0
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
From: =?utf-8?q?Robbie?= <test@mydomain.me>
To: test@mydomain.me
Date: Thu, 16 Feb 2023 01:04:00 -0000
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
This is a multi-part message in MIME format.
--===============2138658284696597373==
Content-Type: multipart/alternative; boundary="===============2233989480071754745=="
MIME-Version: 1.0
--===============2233989480071754745==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
--===============2233989480071754745==
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
--===============2233989480071754745==--
--===============2138658284696597373==--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title"))
require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Content-Type: multipart/mixed; boundary="===============1=="
MIME-Version: 1.0
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
From: =?utf-8?q?Robbie?= <test@mydomain.me>
To: test@mydomain.me
Date: Thu, 16 Feb 2023 01:04:00 -0000
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
This is a multi-part message in MIME format.
--===============1==
Content-Type: multipart/alternative; boundary="===============2=="
MIME-Version: 1.0
--===============2==
Content-Type: multipart/alternative; boundary="===============3=="
MIME-Version: 1.0
--===============3==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
--===============3==
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
--===============3==--
--===============2==--
--===============1==--
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("This should not be called")
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep")
}
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic+tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2@ntfy.sh
DATA
Subject: Very short mail
what's up
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Very short mail", r.Header.Get("Title"))
require.Equal(t, "Bearer tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2", r.Header.Get("Authorization"))
require.Equal(t, "what's up", readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {

View File

@ -1,19 +1,9 @@
package server
import (
"heckel.io/ntfy/log"
"math/rand"
"sync"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
const (
// topicExpungeAfter defines how long a topic is active before it is removed from memory.
// This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give
// time for more requests to come in, so that we can send a {"rejected":["<pushkey>"]} response back.
topicExpungeAfter = 16 * time.Hour
)
// topic represents a channel to which subscribers can subscribe, and publishers
@ -21,9 +11,7 @@ const (
type topic struct {
ID string
subscribers map[int]*topicSubscriber
rateVisitor *visitor
lastAccess time.Time
mu sync.RWMutex
mu sync.Mutex
}
type topicSubscriber struct {
@ -40,61 +28,22 @@ func newTopic(id string) *topic {
return &topic{
ID: id,
subscribers: make(map[int]*topicSubscriber),
lastAccess: time.Now(),
}
}
// Subscribe subscribes to this topic
func (t *topic) Subscribe(s subscriber, userID string, cancel func()) (subscriberID int) {
func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
t.mu.Lock()
defer t.mu.Unlock()
for i := 0; i < 5; i++ { // Best effort retry
subscriberID = rand.Int()
_, exists := t.subscribers[subscriberID]
if !exists {
break
}
}
subscriberID := rand.Int()
t.subscribers[subscriberID] = &topicSubscriber{
userID: userID, // May be empty
subscriber: s,
cancel: cancel,
}
t.lastAccess = time.Now()
return subscriberID
}
func (t *topic) Stale() bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.rateVisitor != nil && !t.rateVisitor.Stale() {
return false
}
return len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter
}
func (t *topic) LastAccess() time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
return t.lastAccess
}
func (t *topic) SetRateVisitor(v *visitor) {
t.mu.Lock()
defer t.mu.Unlock()
t.rateVisitor = v
t.lastAccess = time.Now()
}
func (t *topic) RateVisitor() *visitor {
t.mu.Lock()
defer t.mu.Unlock()
if t.rateVisitor != nil && t.rateVisitor.Stale() {
t.rateVisitor = nil
}
return t.rateVisitor
}
// Unsubscribe removes the subscription from the list of subscribers
func (t *topic) Unsubscribe(id int) {
t.mu.Lock()
@ -122,75 +71,29 @@ func (t *topic) Publish(v *visitor, m *message) error {
} else {
logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
}
t.Keepalive()
}()
return nil
}
// Stats returns the number of subscribers and last access to this topic
func (t *topic) Stats() (int, time.Time) {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.subscribers), t.lastAccess
}
// Keepalive sets the last access time and ensures that Stale does not return true
func (t *topic) Keepalive() {
// SubscribersCount returns the number of subscribers to this topic
func (t *topic) SubscribersCount() int {
t.mu.Lock()
defer t.mu.Unlock()
t.lastAccess = time.Now()
return len(t.subscribers)
}
// CancelSubscribersExceptUser calls the cancel function for all subscribers, forcing
func (t *topic) CancelSubscribersExceptUser(exceptUserID string) {
// CancelSubscribers calls the cancel function for all subscribers, forcing
func (t *topic) CancelSubscribers(exceptUserID string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, s := range t.subscribers {
if s.userID != exceptUserID {
t.cancelUserSubscriber(s)
log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID)
s.cancel()
}
}
}
// CancelSubscriberUser kills the subscriber with the given user ID
func (t *topic) CancelSubscriberUser(userID string) {
t.mu.RLock()
defer t.mu.RUnlock()
for _, s := range t.subscribers {
if s.userID == userID {
t.cancelUserSubscriber(s)
return
}
}
}
func (t *topic) cancelUserSubscriber(s *topicSubscriber) {
log.
Tag(tagSubscribe).
With(t).
Fields(log.Context{
"user_id": s.userID,
}).
Debug("Canceling subscriber with user ID %s", s.userID)
s.cancel()
}
func (t *topic) Context() log.Context {
t.mu.RLock()
defer t.mu.RUnlock()
fields := map[string]any{
"topic": t.ID,
"topic_subscribers": len(t.subscribers),
"topic_last_access": util.FormatTime(t.lastAccess),
}
if t.rateVisitor != nil {
for k, v := range t.rateVisitor.Context() {
fields["topic_rate_"+k] = v
}
}
return fields
}
// subscribersCopy returns a shallow copy of the subscribers map
func (t *topic) subscribersCopy() map[int]*topicSubscriber {
t.mu.Lock()

View File

@ -1,92 +0,0 @@
package server
import (
"math/rand"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
t.Parallel()
subFn := func(v *visitor, msg *message) error {
return nil
}
canceled1 := atomic.Bool{}
cancelFn1 := func() {
canceled1.Store(true)
}
canceled2 := atomic.Bool{}
cancelFn2 := func() {
canceled2.Store(true)
}
to := newTopic("mytopic")
to.Subscribe(subFn, "", cancelFn1)
to.Subscribe(subFn, "u_phil", cancelFn2)
to.CancelSubscribersExceptUser("u_phil")
require.True(t, canceled1.Load())
require.False(t, canceled2.Load())
}
func TestTopic_CancelSubscribersUser(t *testing.T) {
t.Parallel()
subFn := func(v *visitor, msg *message) error {
return nil
}
canceled1 := atomic.Bool{}
cancelFn1 := func() {
canceled1.Store(true)
}
canceled2 := atomic.Bool{}
cancelFn2 := func() {
canceled2.Store(true)
}
to := newTopic("mytopic")
to.Subscribe(subFn, "u_another", cancelFn1)
to.Subscribe(subFn, "u_phil", cancelFn2)
to.CancelSubscriberUser("u_phil")
require.False(t, canceled1.Load())
require.True(t, canceled2.Load())
}
func TestTopic_Keepalive(t *testing.T) {
t.Parallel()
to := newTopic("mytopic")
to.lastAccess = time.Now().Add(-1 * time.Hour)
to.Keepalive()
require.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2)
require.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2)
}
func TestTopic_Subscribe_DuplicateID(t *testing.T) {
t.Parallel()
to := newTopic("mytopic")
// Fix random seed to force same number generation
rand.Seed(1)
a := rand.Int()
to.subscribers[a] = &topicSubscriber{
userID: "a",
subscriber: nil,
cancel: func() {},
}
subFn := func(v *visitor, msg *message) error {
return nil
}
// Force rand.Int to generate the same id once more
rand.Seed(1)
id := to.Subscribe(subFn, "b", func() {})
res := to.subscribers[id]
require.NotEqual(t, id, a)
require.Equal(t, "b", res.userID, "b")
}

View File

@ -45,10 +45,10 @@ type message struct {
func (m *message) Context() log.Context {
fields := map[string]any{
"topic": m.Topic,
"message_id": m.ID,
"message_time": m.Time,
"message_event": m.Event,
"message_topic": m.Topic,
"message_body_size": len(m.Message),
}
if m.Sender.IsValid() {
@ -101,7 +101,6 @@ type publishMessage struct {
Attach string `json:"attach"`
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Delay string `json:"delay"`
}
@ -240,45 +239,6 @@ type apiHealthResponse struct {
Healthy bool `json:"healthy"`
}
type apiStatsResponse struct {
Messages int64 `json:"messages"`
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
}
type apiUserAddRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Tier string `json:"tier"`
// Do not add 'role' here. We don't want to add admins via the API.
}
type apiUserResponse struct {
Username string `json:"username"`
Role string `json:"role"`
Tier string `json:"tier,omitempty"`
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
}
type apiUserGrantResponse struct {
Topic string `json:"topic"` // This may be a pattern
Permission string `json:"permission"`
}
type apiUserDeleteRequest struct {
Username string `json:"username"`
}
type apiAccessAllowRequest struct {
Username string `json:"username"`
Topic string `json:"topic"` // This may be a pattern
Permission string `json:"permission"`
}
type apiAccessResetRequest struct {
Username string `json:"username"`
Topic string `json:"topic"`
}
type apiAccountCreateRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@ -312,16 +272,6 @@ type apiAccountTokenResponse struct {
Expires int64 `json:"expires,omitempty"` // Unix timestamp
}
type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"`
Channel string `json:"channel"`
}
type apiAccountPhoneNumberAddRequest struct {
Number string `json:"number"`
Code string `json:"code"` // Only set when adding a phone number
}
type apiAccountTier struct {
Code string `json:"code"`
Name string `json:"name"`
@ -332,7 +282,6 @@ type apiAccountLimits struct {
Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"`
Calls int64 `json:"calls"`
Reservations int64 `json:"reservations"`
AttachmentTotalSize int64 `json:"attachment_total_size"`
AttachmentFileSize int64 `json:"attachment_file_size"`
@ -345,8 +294,6 @@ type apiAccountStats struct {
MessagesRemaining int64 `json:"messages_remaining"`
Emails int64 `json:"emails"`
EmailsRemaining int64 `json:"emails_remaining"`
Calls int64 `json:"calls"`
CallsRemaining int64 `json:"calls_remaining"`
Reservations int64 `json:"reservations"`
ReservationsRemaining int64 `json:"reservations_remaining"`
AttachmentTotalSize int64 `json:"attachment_total_size"`
@ -362,7 +309,6 @@ type apiAccountBilling struct {
Customer bool `json:"customer"`
Subscription bool `json:"subscription"`
Status string `json:"status,omitempty"`
Interval string `json:"interval,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"`
CancelAt int64 `json:"cancel_at,omitempty"`
}
@ -376,7 +322,6 @@ type apiAccountResponse struct {
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
@ -394,23 +339,15 @@ type apiConfigResponse struct {
EnableLogin bool `json:"enable_login"`
EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableReservations bool `json:"enable_reservations"`
BillingContact string `json:"billing_contact"`
DisallowedTopics []string `json:"disallowed_topics"`
}
type apiAccountBillingPrices struct {
Month int64 `json:"month"`
Year int64 `json:"year"`
}
type apiAccountBillingTier struct {
Code string `json:"code,omitempty"`
Name string `json:"name,omitempty"`
Prices *apiAccountBillingPrices `json:"prices,omitempty"`
Limits *apiAccountLimits `json:"limits"`
Code string `json:"code,omitempty"`
Name string `json:"name,omitempty"`
Price string `json:"price,omitempty"`
Limits *apiAccountLimits `json:"limits"`
}
type apiAccountBillingSubscriptionCreateResponse struct {
@ -418,8 +355,7 @@ type apiAccountBillingSubscriptionCreateResponse struct {
}
type apiAccountBillingSubscriptionChangeRequest struct {
Tier string `json:"tier"`
Interval string `json:"interval"`
Tier string `json:"tier"`
}
type apiAccountBillingPortalRedirectResponse struct {
@ -449,10 +385,7 @@ type apiStripeSubscriptionUpdatedEvent struct {
Items *struct {
Data []*struct {
Price *struct {
ID string `json:"id"`
Recurring *struct {
Interval string `json:"interval"`
} `json:"recurring"`
ID string `json:"id"`
} `json:"price"`
} `json:"data"`
} `json:"items"`

Some files were not shown because too many files have changed in this diff Show More