Compare commits

...

83 Commits

Author SHA1 Message Date
Philipp Heckel
521aad7db5 Increase keepalive duration to 55s 2022-01-01 22:11:09 +01:00
Philipp Heckel
fe2988bb38 Reduce Firebase control channel keepalive message 2022-01-01 22:08:55 +01:00
Philipp Heckel
65a53c1100 Bump version 2022-01-01 21:43:13 +01:00
Philipp Heckel
a53f18ca7d Docs for UnifiedPush, update docs for message limit 2022-01-01 17:45:18 +01:00
Philipp Heckel
595ea87465 Switch VARCHAR(N) to TEXT, as they are equivalent in SQLite 2021-12-31 16:19:41 +01:00
Philipp Heckel
7b37141e07 Increase message size limit to 4096 2021-12-31 16:12:53 +01:00
Philipp Heckel
1fd327325f Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-12-30 01:15:20 +01:00
Philipp Heckel
96ad49f675 Make build-simple work again 2021-12-30 01:15:02 +01:00
Philipp C. Heckel
35b2ca51d8 Merge pull request #74 from ramonsnir/patch-2
Update Docker installation with a Dockerfile example
2021-12-30 00:46:25 +01:00
Ramon Snir
76a28b4e8b Update Docker installation with a Dockerfile example 2021-12-29 18:25:17 -05:00
Philipp Heckel
9752bd7c30 Fix missing SMTP config options in docs 2021-12-29 14:18:38 +01:00
Philipp Heckel
46c0039a16 Bump version 2021-12-28 17:42:31 +01:00
Philipp Heckel
d5497908bb Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-12-28 17:40:53 +01:00
Philipp Heckel
dac88391c1 Docs docs docs 2021-12-28 17:36:12 +01:00
Philipp Heckel
a46a520bca Fix tests 2021-12-28 01:48:58 +01:00
Philipp Heckel
04719f8dee Flip title and message if message is empty 2021-12-28 01:41:00 +01:00
Philipp Heckel
113053a9e3 Fix encoding issues 2021-12-28 01:26:20 +01:00
Philipp Heckel
7cfe909644 CLI arguments 2021-12-27 22:27:01 +01:00
Philipp Heckel
01a1d981cf fix nil pointer 2021-12-27 22:18:15 +01:00
Philipp Heckel
e7f8fc93e4 Working prefix 2021-12-27 22:06:40 +01:00
Philipp C. Heckel
b45ca6f2c0 Merge pull request #68 from arjan-s/archlinux_instructions
Add Arch Linux installation instructions
2021-12-27 17:46:17 +01:00
Arjan Schrijver
be17294dc2 Add Arch Linux installation instructions 2021-12-27 17:39:42 +01:00
Philipp Heckel
7eaa92cb20 WIP 2021-12-27 16:39:28 +01:00
Philipp Heckel
3001e57bcc WIP: mail publish 2021-12-27 15:48:09 +01:00
Philipp Heckel
43a2acb756 Typo 2021-12-27 00:37:18 +01:00
Philipp Heckel
bcc424f2aa Oops 2021-12-26 14:36:38 +01:00
Philipp Heckel
ec7e58a6a2 Fix santa bug, email subject encoding, closes #65 2021-12-26 14:34:25 +01:00
Philipp C. Heckel
9a0f1f22b8 Merge pull request #64 from binwiederhier/up
WIP: unified push
2021-12-26 14:03:45 +01:00
Philipp Heckel
d6762276f5 Test 2021-12-25 22:07:55 +01:00
Philipp Heckel
41514cd557 Merge branch 'main' into up 2021-12-25 21:49:47 +01:00
Karmanyaah Malhotra
63a29380a9 up testing 2021-12-25 10:26:18 -06:00
Philipp Heckel
eeb378cfdc Change error JSON 2021-12-25 15:21:41 +01:00
Philipp Heckel
7a23779d07 JSON API errors 2021-12-25 15:15:05 +01:00
Philipp Heckel
29628a66a6 Initial 2021-12-25 11:56:02 +01:00
Philipp Heckel
020c058805 Bump version 2021-12-25 11:22:27 +01:00
Philipp Heckel
8a625ef786 Docs, and fixing tests 2021-12-25 10:35:08 +01:00
Philipp Heckel
3bc8ff0104 Docs 2021-12-25 00:57:02 +01:00
Philipp Heckel
11b5ac49c0 Fully working email feature 2021-12-25 00:13:09 +01:00
Philipp Heckel
f553cdb282 Continued e-mail support 2021-12-24 15:01:29 +01:00
Philipp Heckel
6b46eb46e2 A mutex in a test struct ... 2021-12-24 00:10:22 +01:00
Philipp Heckel
7280ae1ebc Email rate limiting + tests 2021-12-24 00:03:04 +01:00
Philipp Heckel
873c57b3d8 Send emails 2021-12-23 21:04:17 +01:00
Philipp Heckel
c8c53eed07 Bump version 2021-12-23 00:18:13 +01:00
Philipp Heckel
6779d9dd1f Add NTFY_RAW 2021-12-23 00:16:28 +01:00
Philipp Heckel
85939618c8 Fix Poll(); yeyy tests 2021-12-22 23:45:19 +01:00
Philipp Heckel
fe5734d9f0 Tests for cliet package 2021-12-22 23:20:43 +01:00
Philipp Heckel
6a7e9071b6 Tests, client tests WIP 2021-12-22 14:17:50 +01:00
Philipp Heckel
68d881291c Docs, LoadConfig, config test 2021-12-22 13:46:17 +01:00
Philipp Heckel
66c749d5f0 Include image file, docs 2021-12-22 10:21:59 +01:00
Philipp Heckel
534fca0d3b Fix data race 2021-12-22 10:04:59 +01:00
Philipp Heckel
b6120cf6d7 Message filtering tests 2021-12-22 09:44:16 +01:00
Philipp Heckel
09bf13bd70 Query filters only apply to message events 2021-12-21 21:29:37 +01:00
Philipp Heckel
9315829bc4 Query filters 2021-12-21 21:22:27 +01:00
Philipp Heckel
85b4abde6c Docs 2021-12-20 20:46:51 -05:00
Philipp Heckel
edb6b0cf06 More docs, still WIP 2021-12-19 23:04:55 -05:00
Philipp Heckel
f24855ca9a Documentation, fix test, return JSON on publish, add --quiet flag for publish 2021-12-19 21:01:49 -05:00
Philipp Heckel
ddd5ce2c21 Start CLI tests 2021-12-19 18:32:16 -05:00
Philipp Heckel
e3dfea1991 Docblocks, a handful of tests, but not enough 2021-12-19 14:27:26 -05:00
Philipp Heckel
fa9d6444f5 Move config files and folders 2021-12-18 22:02:36 -05:00
Philipp Heckel
2c1989beb0 I think we're getting there 2021-12-18 16:12:36 -05:00
Philipp Heckel
f266afa1de WIP CLI 2021-12-18 14:43:27 -05:00
Philipp Heckel
5639cf7a0f Docs 2021-12-17 22:38:29 -05:00
Philipp Heckel
a1f513f6a5 WIP CLI 2021-12-17 09:32:59 -05:00
Philipp Heckel
1e8421e8ce WIP: CLI, relates to #46 2021-12-16 20:33:01 -05:00
Philipp Heckel
4346f55b29 Python examples; that's all; closes #50 2021-12-15 20:37:21 -05:00
Philipp Heckel
92f48fbbea Bump version 2021-12-15 19:24:38 -05:00
Philipp Heckel
200dd25ffa Add limitations section 2021-12-15 19:23:44 -05:00
Philipp Heckel
534b93e142 Webhooks (#55), more tests (#35) and python examples (#50) 2021-12-15 16:12:40 -05:00
Philipp Heckel
02f8a32b46 GET-based send/trigger, relates to #55 2021-12-15 09:41:55 -05:00
Philipp Heckel
9cb48dbb60 Move background tasks to functions 2021-12-15 09:13:16 -05:00
Philipp Heckel
bd09fb4c54 Bump version 2021-12-13 22:35:54 -05:00
Philipp Heckel
63206f8581 Firebase keepalive, supports #56 2021-12-13 22:30:28 -05:00
Philipp Heckel
de0c41ec3b Resize/compress images 2021-12-12 20:37:38 -05:00
Philipp Heckel
eaefb436d6 More docs 2021-12-12 14:26:24 -05:00
Philipp Heckel
5843de5dfc Documentation for the intent stuff 2021-12-12 09:26:35 -05:00
Philipp Heckel
6abda93a14 Bump version 2021-12-11 07:30:48 -05:00
Philipp C. Heckel
281faeff3b Merge pull request #48 from binwiederhier/delay
WIP: Add 'At:'/'In:' headers to support scheduled messages
2021-12-11 00:14:17 -05:00
Philipp Heckel
01d21165e9 Docs docs docs 2021-12-11 00:06:25 -05:00
Philipp Heckel
e8688fed4b Lots more tests 2021-12-10 22:57:01 -05:00
Philipp Heckel
5ef83a7ba0 Test DB migration 2021-12-10 20:28:56 -05:00
Philipp Heckel
06b4d9c83b Natural language 2021-12-10 19:59:51 -05:00
Philipp Heckel
196c86d12b WIP_ Add 'At:'/'Delay:' headers to support scheduled messages 2021-12-10 11:31:42 -05:00
Philipp C. Heckel
b45f95e392 Update README.md 2021-12-09 19:44:59 -05:00
109 changed files with 5141 additions and 714 deletions

View File

@@ -11,7 +11,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
run: sudo apt update && sudo apt install -y python3-pip
run: sudo apt update && sudo apt install -y python3-pip curl
- name: Build docs (required for tests)
run: make docs
- name: Run tests, formatting, vetting and linting

1
.gitignore vendored
View File

@@ -2,4 +2,5 @@ dist/
build/
.idea/
server/docs/
tools/fbsend/fbsend
*.iml

View File

@@ -47,14 +47,22 @@ nfpms:
- rpm
bindir: /usr/bin
contents:
- src: config/config.yml
dst: /etc/ntfy/config.yml
- src: server/server.yml
dst: /etc/ntfy/server.yml
type: config
- src: config/ntfy.service
- src: server/ntfy.service
dst: /lib/systemd/system/ntfy.service
- src: client/client.yml
dst: /etc/ntfy/client.yml
type: config
- src: client/ntfy-client.service
dst: /lib/systemd/system/ntfy-client.service
- dst: /var/cache/ntfy
type: dir
- dst: /usr/share/ntfy/logo.png
src: server/static/img/ntfy.png
scripts:
preinstall: "scripts/preinst.sh"
postinstall: "scripts/postinst.sh"
preremove: "scripts/prerm.sh"
postremove: "scripts/postrm.sh"
@@ -64,8 +72,10 @@ archives:
files:
- LICENSE
- README.md
- config/config.yml
- config/ntfy.service
- server/server.yml
- server/ntfy.service
- client/client.yml
- client/ntfy-client.service
replacements:
386: i386
amd64: x86_64

View File

@@ -1,4 +1,3 @@
GO=$(shell which go)
VERSION := $(shell git describe --tag)
.PHONY:
@@ -50,20 +49,20 @@ docs: docs-deps
check: test fmt-check vet lint staticcheck
test: .PHONY
$(GO) test ./...
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
race: .PHONY
$(GO) test -race ./...
go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
coverage:
mkdir -p build/coverage
$(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./...
$(GO) tool cover -func build/coverage/coverage.txt
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go tool cover -func build/coverage/coverage.txt
coverage-html:
mkdir -p build/coverage
$(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./...
$(GO) tool cover -html build/coverage/coverage.txt
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go tool cover -html build/coverage/coverage.txt
coverage-upload:
cd build/coverage && (curl -s https://codecov.io/bash | bash)
@@ -78,17 +77,17 @@ fmt-check:
test -z $(shell gofmt -l .)
vet:
$(GO) vet ./...
go vet ./...
lint:
which golint || $(GO) get -u golang.org/x/lint/golint
$(GO) list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
which golint || go get -u golang.org/x/lint/golint
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
staticcheck: .PHONY
rm -rf build/staticcheck
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
mkdir -p build/staticcheck
ln -s "$(GO)" build/staticcheck/go
ln -s "go" build/staticcheck/go
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
rm -rf build/staticcheck
@@ -106,16 +105,17 @@ build-snapshot: build-deps
goreleaser build --snapshot --rm-dist --debug
build-simple: clean
mkdir -p dist/ntfy_linux_amd64
mkdir -p dist/ntfy_linux_amd64 server/docs
touch server/docs/dummy
export CGO_ENABLED=1
$(GO) build \
go build \
-o dist/ntfy_linux_amd64/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
clean: .PHONY
rm -rf dist build
rm -rf dist build server/docs
# Releasing targets

View File

@@ -6,7 +6,7 @@
[![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions)
[![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy)
[![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy)
[![Slack channel](https://img.shields.io/badge/slack-@gophers/binwiederhier-success.svg?logo=slack)](https://gophers.slack.com/archives/C01JMTPGF2Q)
[![Discord](https://img.shields.io/discord/874398661709295626)](https://discord.gg/cT7ECsZj9w)
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
@@ -36,7 +36,7 @@ too.
I welcome any and all contributions. Just create a PR or an issue.
## Contact me
You can directly contact me [on Slack](https://gophers.slack.com/archives/C01JMTPGF2Q), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues),
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)**, or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues),
or find more contact information [on my website](https://heckel.io/about).
## License
@@ -48,6 +48,8 @@ Third party libraries and resources:
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)

257
client/client.go Normal file
View File

@@ -0,0 +1,257 @@
// Package client provides a ntfy client to publish and subscribe to topics
package client
import (
"bufio"
"context"
"encoding/json"
"fmt"
"heckel.io/ntfy/util"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// Event type constants
const (
MessageEvent = "message"
KeepaliveEvent = "keepalive"
OpenEvent = "open"
)
const (
maxResponseBytes = 4096
)
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
type Client struct {
Messages chan *Message
config *Config
subscriptions map[string]*subscription
mu sync.Mutex
}
// Message is a struct that represents a ntfy message
type Message struct { // TODO combine with server.message
ID string
Event string
Time int64
Topic string
Message string
Title string
Priority int
Tags []string
// Additional fields
TopicURL string
SubscriptionID string
Raw string
}
type subscription struct {
ID string
topicURL string
cancel context.CancelFunc
}
// New creates a new Client using a given Config
func New(config *Config) *Client {
return &Client{
Messages: make(chan *Message, 50), // Allow reading a few messages
config: config,
subscriptions: make(map[string]*subscription),
}
}
// Publish sends a message to a specific topic, optionally using options.
//
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
//
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
// WithNoFirebase, and the generic WithHeader.
func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
topicURL := c.expandTopicURL(topic)
req, _ := http.NewRequest("POST", topicURL, strings.NewReader(message))
for _, option := range options {
if err := option(req); err != nil {
return nil, err
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected response %d from server", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, err
}
m, err := toMessage(string(b), topicURL, "")
if err != nil {
return nil, err
}
return m, nil
}
// Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for
// messages and does not subscribe to messages that arrive after this call.
//
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
//
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
ctx := context.Background()
messages := make([]*Message, 0)
msgChan := make(chan *Message)
errChan := make(chan error)
topicURL := c.expandTopicURL(topic)
options = append(options, WithPoll())
go func() {
err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
close(msgChan)
errChan <- err
}()
for m := range msgChan {
messages = append(messages, m)
}
return messages, <-errChan
}
// Subscribe subscribes to a topic to listen for newly incoming messages. The method starts a connection in the
// background and returns new messages via the Messages channel.
//
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
//
// By default, only new messages will be returned, but you can change this behavior using a SubscribeOption.
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
//
// The method returns a unique subscriptionID that can be used in Unsubscribe.
//
// Example:
// c := client.New(client.NewConfig())
// subscriptionID := c.Subscribe("mytopic")
// for m := range c.Messages {
// fmt.Printf("New message: %s", m.Message)
// }
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
c.mu.Lock()
defer c.mu.Unlock()
subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic)
ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{
ID: subscriptionID,
topicURL: topicURL,
cancel: cancel,
}
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
return subscriptionID
}
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
// subscriptionID returned in Subscribe.
func (c *Client) Unsubscribe(subscriptionID string) {
c.mu.Lock()
defer c.mu.Unlock()
sub, ok := c.subscriptions[subscriptionID]
if !ok {
return
}
delete(c.subscriptions, subscriptionID)
sub.cancel()
}
// 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
} else if strings.Contains(topic, "/") {
return fmt.Sprintf("https://%s", topic)
}
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
}
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
for {
// TODO The retry logic is crude and may lose messages. It should record the last message like the
// Android client, use since=, and do incremental backoff too
if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {
log.Printf("Connection to %s failed: %s", topicURL, err.Error())
}
select {
case <-ctx.Done():
log.Printf("Connection to %s exited", topicURL)
return
case <-time.After(10 * time.Second): // TODO Add incremental backoff
}
}
}
func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil)
if err != nil {
return err
}
for _, option := range options {
if err := option(req); err != nil {
return err
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
if err != nil {
return err
}
if m.Event == MessageEvent {
msgChan <- m
}
}
return nil
}
func toMessage(s, topicURL, subscriptionID string) (*Message, error) {
var m *Message
if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil {
return nil, err
}
m.TopicURL = topicURL
m.SubscriptionID = subscriptionID
m.Raw = s
return m, nil
}

36
client/client.yml Normal file
View File

@@ -0,0 +1,36 @@
# ntfy client config file
# Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands.
# If you self-host a ntfy server, you'll likely want to change this.
#
# default-host: https://ntfy.sh
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
# or if you cann "ntfy subscribe --from-config" directly.
#
# Example:
# subscribe:
# - topic: mytopic
# command: /usr/local/bin/mytopic-triggered.sh
# - topic: myserver.com/anothertopic
# command: 'echo "$message"'
# if:
# priority: high,urgent
#
# Variables:
# Variable Aliases Description
# --------------- --------------------- -----------------------------------
# $NTFY_ID $id Unique message ID
# $NTFY_TIME $time Unix timestamp of the message delivery
# $NTFY_TOPIC $topic Topic name
# $NTFY_MESSAGE $message, $m Message body
# $NTFY_TITLE $title, $t Message title
# $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
# $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
# $NTFY_RAW $raw Raw JSON message
#
# Filters ('if:'):
# You can filter 'message', 'title', 'priority' (comma-separated list, logical OR)
# and 'tags' (comma-separated list, logical AND). See https://ntfy.sh/docs/subscribe/api/#filter-messages.
#
# subscribe:

110
client/client_test.go Normal file
View File

@@ -0,0 +1,110 @@
package client_test
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/client"
"heckel.io/ntfy/test"
"testing"
"time"
)
func TestClient_Publish_Subscribe(t *testing.T) {
s, port := test.StartServer(t)
defer test.StopServer(t, s, port)
c := client.New(newTestConfig(port))
subscriptionID := c.Subscribe("mytopic")
time.Sleep(time.Second)
msg, err := c.Publish("mytopic", "some message")
require.Nil(t, err)
require.Equal(t, "some message", msg.Message)
msg, err = c.Publish("mytopic", "some other message",
client.WithTitle("some title"),
client.WithPriority("high"),
client.WithTags([]string{"tag1", "tag 2"}))
require.Nil(t, err)
require.Equal(t, "some other message", msg.Message)
require.Equal(t, "some title", msg.Title)
require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags)
require.Equal(t, 4, msg.Priority)
msg, err = c.Publish("mytopic", "some delayed message",
client.WithDelay("25 hours"))
require.Nil(t, err)
require.Equal(t, "some delayed message", msg.Message)
require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time)
time.Sleep(200 * time.Millisecond)
msg = nextMessage(c)
require.NotNil(t, msg)
require.Equal(t, "some message", msg.Message)
msg = nextMessage(c)
require.NotNil(t, msg)
require.Equal(t, "some other message", msg.Message)
require.Equal(t, "some title", msg.Title)
require.Equal(t, []string{"tag1", "tag 2"}, msg.Tags)
require.Equal(t, 4, msg.Priority)
msg = nextMessage(c)
require.Nil(t, msg)
c.Unsubscribe(subscriptionID)
time.Sleep(200 * time.Millisecond)
msg, err = c.Publish("mytopic", "a message that won't be received")
require.Nil(t, err)
require.Equal(t, "a message that won't be received", msg.Message)
msg = nextMessage(c)
require.Nil(t, msg)
}
func TestClient_Publish_Poll(t *testing.T) {
s, port := test.StartServer(t)
defer test.StopServer(t, s, port)
c := client.New(newTestConfig(port))
msg, err := c.Publish("mytopic", "some message", client.WithNoFirebase(), client.WithTagsList("tag1,tag2"))
require.Nil(t, err)
require.Equal(t, "some message", msg.Message)
require.Equal(t, []string{"tag1", "tag2"}, msg.Tags)
msg, err = c.Publish("mytopic", "this won't be cached", client.WithNoCache())
require.Nil(t, err)
require.Equal(t, "this won't be cached", msg.Message)
msg, err = c.Publish("mytopic", "some delayed message", client.WithDelay("20 min"))
require.Nil(t, err)
require.Equal(t, "some delayed message", msg.Message)
messages, err := c.Poll("mytopic")
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "some message", messages[0].Message)
messages, err = c.Poll("mytopic", client.WithScheduled())
require.Nil(t, err)
require.Equal(t, 2, len(messages))
require.Equal(t, "some message", messages[0].Message)
require.Equal(t, "some delayed message", messages[1].Message)
}
func newTestConfig(port int) *client.Config {
c := client.NewConfig()
c.DefaultHost = fmt.Sprintf("http://127.0.0.1:%d", port)
return c
}
func nextMessage(c *client.Client) *client.Message {
select {
case m := <-c.Messages:
return m
default:
return nil
}
}

42
client/config.go Normal file
View File

@@ -0,0 +1,42 @@
package client
import (
"gopkg.in/yaml.v2"
"os"
)
const (
// DefaultBaseURL is the base URL used to expand short topic names
DefaultBaseURL = "https://ntfy.sh"
)
// Config is the config struct for a Client
type Config struct {
DefaultHost string `yaml:"default-host"`
Subscribe []struct {
Topic string `yaml:"topic"`
Command string `yaml:"command"`
If map[string]string `yaml:"if"`
} `yaml:"subscribe"`
}
// NewConfig creates a new Config struct for a Client
func NewConfig() *Config {
return &Config{
DefaultHost: DefaultBaseURL,
Subscribe: nil,
}
}
// LoadConfig loads the Client config from a yaml file
func LoadConfig(filename string) (*Config, error) {
b, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
c := NewConfig()
if err := yaml.Unmarshal(b, c); err != nil {
return nil, err
}
return c, nil
}

36
client/config_test.go Normal file
View File

@@ -0,0 +1,36 @@
package client_test
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/client"
"os"
"path/filepath"
"testing"
)
func TestConfig_Load(t *testing.T) {
filename := filepath.Join(t.TempDir(), "client.yml")
require.Nil(t, os.WriteFile(filename, []byte(`
default-host: http://localhost
subscribe:
- topic: no-command
- topic: echo-this
command: 'echo "Message received: $message"'
- topic: alerts
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
if:
priority: high,urgent
`), 0600))
conf, err := client.LoadConfig(filename)
require.Nil(t, err)
require.Equal(t, "http://localhost", conf.DefaultHost)
require.Equal(t, 3, len(conf.Subscribe))
require.Equal(t, "no-command", conf.Subscribe[0].Topic)
require.Equal(t, "", conf.Subscribe[0].Command)
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command)
require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"])
}

View File

@@ -0,0 +1,12 @@
[Unit]
Description=ntfy client
After=network.target
[Service]
User=ntfy
Group=ntfy
ExecStart=/usr/bin/ntfy subscribe --config /etc/ntfy/client.yml --from-config
Restart=on-failure
[Install]
WantedBy=multi-user.target

142
client/options.go Normal file
View File

@@ -0,0 +1,142 @@
package client
import (
"fmt"
"net/http"
"strings"
"time"
)
// RequestOption is a generic request option that can be added to Client calls
type RequestOption = func(r *http.Request) error
// PublishOption is an option that can be passed to the Client.Publish call
type PublishOption = RequestOption
// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call
type SubscribeOption = RequestOption
// WithTitle adds a title to a message
func WithTitle(title string) PublishOption {
return WithHeader("X-Title", title)
}
// WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max),
// or the corresponding names (see util.ParsePriority).
func WithPriority(priority string) PublishOption {
return WithHeader("X-Priority", priority)
}
// WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list
// of tags. To use a slice, use WithTags instead
func WithTagsList(tags string) PublishOption {
return WithHeader("X-Tags", tags)
}
// WithTags adds a list of a tags to a message
func WithTags(tags []string) PublishOption {
return WithTagsList(strings.Join(tags, ","))
}
// WithDelay instructs the server to send the message at a later date. The delay parameter can be a
// Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery
// for details.
func WithDelay(delay string) PublishOption {
return WithHeader("X-Delay", delay)
}
// WithEmail instructs the server to also send the message to the given e-mail address
func WithEmail(email string) PublishOption {
return WithHeader("X-Email", email)
}
// WithNoCache instructs the server not to cache the message server-side
func WithNoCache() PublishOption {
return WithHeader("X-Cache", "no")
}
// WithNoFirebase instructs the server not to forward the message to Firebase
func WithNoFirebase() PublishOption {
return WithHeader("X-Firebase", "no")
}
// WithSince limits the number of messages returned from the server. The parameter since can be a Unix
// timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word "all" (see WithSinceAll).
func WithSince(since string) SubscribeOption {
return WithQueryParam("since", since)
}
// WithSinceAll instructs the server to return all messages for the given topic from the server
func WithSinceAll() SubscribeOption {
return WithSince("all")
}
// WithSinceDuration instructs the server to return all messages since the given duration ago
func WithSinceDuration(since time.Duration) SubscribeOption {
return WithSinceUnixTime(time.Now().Add(-1 * since).Unix())
}
// WithSinceUnixTime instructs the server to return only messages newer or equal to the given timestamp
func WithSinceUnixTime(since int64) SubscribeOption {
return WithSince(fmt.Sprintf("%d", since))
}
// WithPoll instructs the server to close the connection after messages have been returned. Don't use this option
// directly. Use Client.Poll instead.
func WithPoll() SubscribeOption {
return WithQueryParam("poll", "1")
}
// WithScheduled instructs the server to also return messages that have not been sent yet, i.e. delayed/scheduled
// messages (see WithDelay). The messages will have a future date.
func WithScheduled() SubscribeOption {
return WithQueryParam("scheduled", "1")
}
// WithFilter is a generic subscribe option meant to be used to filter for certain messages only
func WithFilter(param, value string) SubscribeOption {
return WithQueryParam(param, value)
}
// WithMessageFilter instructs the server to only return messages that match the exact message
func WithMessageFilter(message string) SubscribeOption {
return WithQueryParam("message", message)
}
// WithTitleFilter instructs the server to only return messages with a title that match the exact string
func WithTitleFilter(title string) SubscribeOption {
return WithQueryParam("title", title)
}
// WithPriorityFilter instructs the server to only return messages with the matching priority. Not that messages
// without priority also implicitly match priority 3.
func WithPriorityFilter(priority int) SubscribeOption {
return WithQueryParam("priority", fmt.Sprintf("%d", priority))
}
// WithTagsFilter instructs the server to only return messages that contain all of the given tags
func WithTagsFilter(tags []string) SubscribeOption {
return WithQueryParam("tags", strings.Join(tags, ","))
}
// WithHeader is a generic option to add headers to a request
func WithHeader(header, value string) RequestOption {
return func(r *http.Request) error {
if value != "" {
r.Header.Set(header, value)
}
return nil
}
}
// WithQueryParam is a generic option to add query parameters to a request
func WithQueryParam(param, value string) RequestOption {
return func(r *http.Request) error {
if value != "" {
q := r.URL.Query()
q.Add(param, value)
r.URL.RawQuery = q.Encode()
}
return nil
}
}

View File

@@ -2,112 +2,44 @@
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/config"
"heckel.io/ntfy/server"
"heckel.io/ntfy/util"
"log"
"os"
"time"
)
var (
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
)
// New creates a new CLI application
func New() *cli.App {
flags := []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/config.yml", DefaultText: "/etc/ntfy/config.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: config.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: config.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: config.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: config.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"V"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: config.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"B"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: config.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"R"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: config.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
}
return &cli.App{
Name: "ntfy",
Usage: "Simple pub-sub notification service",
UsageText: "ntfy [OPTION..]",
HideHelp: true,
HideVersion: true,
EnableBashCompletion: true,
UseShortOptionHandling: true,
Reader: os.Stdin,
Writer: os.Stdout,
ErrWriter: os.Stderr,
Action: execRun,
Before: initConfigFileInputSource("config", flags),
Flags: flags,
Action: execMainApp,
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
Flags: flagsServe, // DEPRECATED, see deprecation notice
Commands: []*cli.Command{
cmdServe,
cmdPublish,
cmdSubscribe,
},
}
}
func execRun(c *cli.Context) error {
// Read all the options
listenHTTP := c.String("listen-http")
listenHTTPS := c.String("listen-https")
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval")
globalTopicLimit := c.Int("global-topic-limit")
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
behindProxy := c.Bool("behind-proxy")
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
return errors.New("manager interval cannot be lower than five seconds")
} else if cacheDuration > 0 && cacheDuration < managerInterval {
return errors.New("cache duration cannot be lower than manager interval")
} else if keyFile != "" && !util.FileExists(keyFile) {
return errors.New("if set, key file must exist")
} else if certFile != "" && !util.FileExists(certFile) {
return errors.New("if set, certificate file must exist")
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
return errors.New("if listen-https is set, both key-file and cert-file must be set")
}
// Run server
conf := config.New(listenHTTP)
conf.ListenHTTPS = listenHTTPS
conf.KeyFile = keyFile
conf.CertFile = certFile
conf.FirebaseKeyFile = firebaseKeyFile
conf.CacheFile = cacheFile
conf.CacheDuration = cacheDuration
conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval
conf.GlobalTopicLimit = globalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.BehindProxy = behindProxy
s, err := server.New(conf)
if err != nil {
log.Fatalln(err)
}
if err := s.Run(); err != nil {
log.Fatalln(err)
}
log.Printf("Exiting.")
return nil
func execMainApp(c *cli.Context) error {
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: Please run the server using 'ntfy serve'; see 'ntfy -h' for help.\x1b[0m")
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
return execServe(c)
}
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks

37
cmd/app_test.go Normal file
View File

@@ -0,0 +1,37 @@
package cmd
import (
"bytes"
"encoding/json"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"io"
"log"
"os"
"strings"
"testing"
)
// This only contains helpers so far
func TestMain(m *testing.M) {
log.SetOutput(io.Discard)
os.Exit(m.Run())
}
func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
var stdin, stdout, stderr bytes.Buffer
app := New()
app.Reader = &stdin
app.Writer = &stdout
app.ErrWriter = &stderr
return app, &stdin, &stdout, &stderr
}
func toMessage(t *testing.T, s string) *client.Message {
var m *client.Message
if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil {
t.Fatal(err)
}
return m
}

99
cmd/publish.go Normal file
View File

@@ -0,0 +1,99 @@
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"strings"
)
var cmdPublish = &cli.Command{
Name: "publish",
Aliases: []string{"pub", "send", "trigger"},
Usage: "Send message via a ntfy server",
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]",
Action: execPublish,
Flags: []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, Usage: "message title"},
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
},
Description: `Publish a message to a ntfy server.
Examples:
ntfy publish mytopic This is my message # Send simple message
ntfy send myserver.com/mytopic "This is my message" # Send message to different default host
ntfy pub -p high backups "Backups failed" # Send high priority message
ntfy pub --tags=warning,skull backups "Backups failed" # Add tags/emojis to message
ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s
ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am
ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com
ntfy trigger mywebhook # Sending without message, useful for webhooks
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
it has incredibly useful information: https://ntfy.sh/docs/publish/.
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
or ~/.config/ntfy/client.yml for all other users.`,
}
func execPublish(c *cli.Context) error {
if c.NArg() < 1 {
return errors.New("must specify topic, type 'ntfy publish --help' for help")
}
conf, err := loadConfig(c)
if err != nil {
return err
}
title := c.String("title")
priority := c.String("priority")
tags := c.String("tags")
delay := c.String("delay")
email := c.String("email")
noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase")
quiet := c.Bool("quiet")
topic := c.Args().Get(0)
message := ""
if c.NArg() > 1 {
message = strings.Join(c.Args().Slice()[1:], " ")
}
var options []client.PublishOption
if title != "" {
options = append(options, client.WithTitle(title))
}
if priority != "" {
options = append(options, client.WithPriority(priority))
}
if tags != "" {
options = append(options, client.WithTagsList(tags))
}
if delay != "" {
options = append(options, client.WithDelay(delay))
}
if email != "" {
options = append(options, client.WithEmail(email))
}
if noCache {
options = append(options, client.WithNoCache())
}
if noFirebase {
options = append(options, client.WithNoFirebase())
}
cl := client.New(conf)
m, err := cl.Publish(topic, message, options...)
if err != nil {
return err
}
if !quiet {
fmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw))
}
return nil
}

36
cmd/publish_test.go Normal file
View File

@@ -0,0 +1,36 @@
package cmd
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
"testing"
)
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
testMessage := util.RandomString(10)
app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
app2, _, stdout, _ := newTestApp()
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
require.Contains(t, stdout.String(), testMessage)
}
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
s, port := test.StartServer(t)
defer test.StopServer(t, s, port)
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
app, _, stdout, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", topic, "some message"}))
m := toMessage(t, stdout.String())
require.Equal(t, "some message", m.Message)
app2, _, stdout, _ := newTestApp()
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", topic}))
m = toMessage(t, stdout.String())
require.Equal(t, "some message", m.Message)
}

145
cmd/serve.go Normal file
View File

@@ -0,0 +1,145 @@
package cmd
import (
"errors"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/server"
"heckel.io/ntfy/util"
"log"
"time"
)
var flagsServe = []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"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{"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{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "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", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "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", 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", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "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", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
}
var cmdServe = &cli.Command{
Name: "serve",
Usage: "Run the ntfy server",
UsageText: "ntfy serve [OPTIONS..]",
Action: execServe,
Flags: flagsServe,
Before: initConfigFileInputSource("config", flagsServe),
Description: `Run the ntfy server and listen for incoming requests
The command will load the configuration from /etc/ntfy/server.yml. Config options can
be overridden using the command line options.
Examples:
ntfy serve # Starts server in the foreground (on port 80)
ntfy serve --listen-http :8080 # Starts server with alternate port`,
}
func execServe(c *cli.Context) error {
if c.NArg() > 0 {
return errors.New("no arguments expected, see 'ntfy serve --help' for help")
}
// Read all the options
baseURL := c.String("base-url")
listenHTTP := c.String("listen-http")
listenHTTPS := c.String("listen-https")
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval")
smtpSenderAddr := c.String("smtp-sender-addr")
smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass")
smtpSenderFrom := c.String("smtp-sender-from")
smtpServerListen := c.String("smtp-server-listen")
smtpServerDomain := c.String("smtp-server-domain")
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
globalTopicLimit := c.Int("global-topic-limit")
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
behindProxy := c.Bool("behind-proxy")
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
return errors.New("manager interval cannot be lower than five seconds")
} else if cacheDuration > 0 && cacheDuration < managerInterval {
return errors.New("cache duration cannot be lower than manager interval")
} else if keyFile != "" && !util.FileExists(keyFile) {
return errors.New("if set, key file must exist")
} else if certFile != "" && !util.FileExists(certFile) {
return errors.New("if set, certificate file must exist")
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
return errors.New("if listen-https is set, both key-file and cert-file must be set")
} else if smtpSenderAddr != "" && (baseURL == "" || 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")
}
// Run server
conf := server.NewConfig()
conf.BaseURL = baseURL
conf.ListenHTTP = listenHTTP
conf.ListenHTTPS = listenHTTPS
conf.KeyFile = keyFile
conf.CertFile = certFile
conf.FirebaseKeyFile = firebaseKeyFile
conf.CacheFile = cacheFile
conf.CacheDuration = cacheDuration
conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval
conf.SMTPSenderAddr = smtpSenderAddr
conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass
conf.SMTPSenderFrom = smtpSenderFrom
conf.SMTPServerListen = smtpServerListen
conf.SMTPServerDomain = smtpServerDomain
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
conf.GlobalTopicLimit = globalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.BehindProxy = behindProxy
s, err := server.New(conf)
if err != nil {
log.Fatalln(err)
}
if err := s.Run(); err != nil {
log.Fatalln(err)
}
log.Printf("Exiting.")
return nil
}

237
cmd/subscribe.go Normal file
View File

@@ -0,0 +1,237 @@
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/util"
"log"
"os"
"os/exec"
"os/user"
"strings"
)
var cmdSubscribe = &cli.Command{
Name: "subscribe",
Aliases: []string{"sub"},
Usage: "Subscribe to one or more topics on a ntfy server",
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
Action: execSubscribe,
Flags: []cli.Flag{
&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.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
},
Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
every arriving message. There are 3 modes in which the command can be run:
ntfy subscribe TOPIC
This prints the JSON representation of every incoming message. It is useful when you
have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
this command stays open forever.
Examples:
ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
ntfy sub home.lan/backups # Subscribe to topic on different server
ntfy sub --poll home.lan/backups # Just query for latest messages and exit
ntfy subscribe TOPIC COMMAND
This executes COMMAND for every incoming messages. The message fields are passed to the
command as environment variables:
Variable Aliases Description
--------------- --------------------- -----------------------------------
$NTFY_ID $id Unique message ID
$NTFY_TIME $time Unix timestamp of the message delivery
$NTFY_TOPIC $topic Topic name
$NTFY_MESSAGE $message, $m Message body
$NTFY_TITLE $title, $t Message title
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
$NTFY_RAW $raw Raw JSON message
Examples:
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
ntfy sub topic1 /my/script.sh # Execute script for incoming messages
ntfy subscribe --from-config
Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
block (see config file).
Examples:
ntfy sub --from-config # Read topics from config file
ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
or ~/.config/ntfy/client.yml for all other users.`,
}
func execSubscribe(c *cli.Context) error {
// Read config and options
conf, err := loadConfig(c)
if err != nil {
return err
}
cl := client.New(conf)
since := c.String("since")
poll := c.Bool("poll")
scheduled := c.Bool("scheduled")
fromConfig := c.Bool("from-config")
topic := c.Args().Get(0)
command := c.Args().Get(1)
if !fromConfig {
conf.Subscribe = nil // wipe if --from-config not passed
}
var options []client.SubscribeOption
if since != "" {
options = append(options, client.WithSince(since))
}
if poll {
options = append(options, client.WithPoll())
}
if scheduled {
options = append(options, client.WithScheduled())
}
if topic == "" && len(conf.Subscribe) == 0 {
return errors.New("must specify topic, type 'ntfy subscribe --help' for help")
}
// Execute poll or subscribe
if poll {
return doPoll(c, cl, conf, topic, command, options...)
}
return doSubscribe(c, cl, conf, topic, command, options...)
}
func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
for _, s := range conf.Subscribe { // may be nil
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
return err
}
}
if topic != "" {
if err := doPollSingle(c, cl, topic, command, options...); err != nil {
return err
}
}
return nil
}
func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {
messages, err := cl.Poll(topic, options...)
if err != nil {
return err
}
for _, m := range messages {
printMessageOrRunCommand(c, m, command)
}
return nil
}
func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
commands := make(map[string]string) // Subscription ID -> command
for _, s := range conf.Subscribe { // May be nil
topicOptions := append(make([]client.SubscribeOption, 0), options...)
for filter, value := range s.If {
topicOptions = append(topicOptions, client.WithFilter(filter, value))
}
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
commands[subscriptionID] = s.Command
}
if topic != "" {
subscriptionID := cl.Subscribe(topic, options...)
commands[subscriptionID] = command
}
for m := range cl.Messages {
command, ok := commands[m.SubscriptionID]
if !ok {
continue
}
printMessageOrRunCommand(c, m, command)
}
return nil
}
func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
if command != "" {
runCommand(c, command, m)
} else {
fmt.Fprintln(c.App.Writer, m.Raw)
}
}
func runCommand(c *cli.Context, command string, m *client.Message) {
if err := runCommandInternal(c, command, m); err != nil {
fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error())
}
}
func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
scriptFile, err := createTmpScript(command)
if err != nil {
return err
}
defer os.Remove(scriptFile)
verbose := c.Bool("verbose")
if verbose {
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
}
cmd := exec.Command("sh", "-c", scriptFile)
cmd.Stdin = c.App.Reader
cmd.Stdout = c.App.Writer
cmd.Stderr = c.App.ErrWriter
cmd.Env = envVars(m)
return cmd.Run()
}
func createTmpScript(command string) (string, error) {
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
script := fmt.Sprintf("#!/bin/sh\n%s", command)
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
return "", err
}
return scriptFile, nil
}
func envVars(m *client.Message) []string {
env := os.Environ()
env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...)
env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
return env
}
func envVar(value string, vars ...string) []string {
env := make([]string, 0)
for _, v := range vars {
env = append(env, fmt.Sprintf("%s=%s", v, value))
}
return env
}
func loadConfig(c *cli.Context) (*client.Config, error) {
filename := c.String("config")
if filename != "" {
return client.LoadConfig(filename)
}
u, _ := user.Current()
configFile := defaultClientRootConfigFile
if u.Uid != "0" {
configFile = util.ExpandHome(defaultClientUserConfigFile)
}
if s, _ := os.Stat(configFile); s != nil {
return client.LoadConfig(configFile)
}
return client.NewConfig(), nil
}

View File

@@ -1,63 +0,0 @@
// Package config provides the main configuration
package config
import (
"time"
)
// Defines default config settings
const (
DefaultListenHTTP = ":80"
DefaultCacheDuration = 12 * time.Hour
DefaultKeepaliveInterval = 30 * time.Second
DefaultManagerInterval = time.Minute
)
// Defines all the limits
// - global topic limit: max number of topics overall
// - per visistor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
// - per visistor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
const (
DefaultGlobalTopicLimit = 5000
DefaultVisitorRequestLimitBurst = 60
DefaultVisitorRequestLimitReplenish = 10 * time.Second
DefaultVisitorSubscriptionLimit = 30
)
// Config is the main config struct for the application. Use New to instantiate a default config struct.
type Config struct {
ListenHTTP string
ListenHTTPS string
KeyFile string
CertFile string
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
KeepaliveInterval time.Duration
ManagerInterval time.Duration
GlobalTopicLimit int
VisitorRequestLimitBurst int
VisitorRequestLimitReplenish time.Duration
VisitorSubscriptionLimit int
BehindProxy bool
}
// New instantiates a default new config
func New(listenHTTP string) *Config {
return &Config{
ListenHTTP: listenHTTP,
ListenHTTPS: "",
KeyFile: "",
CertFile: "",
FirebaseKeyFile: "",
CacheFile: "",
CacheDuration: DefaultCacheDuration,
KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval,
GlobalTopicLimit: DefaultGlobalTopicLimit,
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
BehindProxy: false,
}
}

View File

@@ -1,12 +0,0 @@
package config_test
import (
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/config"
"testing"
)
func TestConfig_New(t *testing.T) {
c := config.New(":1234")
assert.Equal(t, ":1234", c.ListenHTTP)
}

View File

@@ -1,19 +1,19 @@
# Configuring the ntfy server
The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/config.yml`,
see [config.yml](https://github.com/binwiederhier/ntfy/blob/main/config/config.yml)), via command line arguments
The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,
see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/config/server.yml)), via command line arguments
or using environment variables.
## Quick start
By default, simply running `ntfy` will start the server at port 80. No configuration needed. Batteries included 😀.
By default, simply running `ntfy serve` will start the server at port 80. No configuration needed. Batteries included 😀.
If everything works as it should, you'll see something like this:
```
$ ntfy
$ ntfy serve
2021/11/30 19:59:08 Listening on :80
```
You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md),
[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure
the server further, check out the [config options table](#config-options) or simply type `ntfy --help` to
the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to
get a list of [command line options](#command-line-options).
## Message cache
@@ -32,26 +32,85 @@ You can also entirely disable the cache by setting `cache-duration` to `0`. When
passed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward
the message to the subscribers.
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#polling), as well as the
[`since=` parameter](subscribe/api.md#fetching-cached-messages).
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
## E-mail notifications
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
following settings:
* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer
* `smtp-sender-addr` is the hostname:port of the SMTP server
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
* `smtp-sender-from` is the e-mail address of the sender
Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is
configured for `ntfy.sh`):
=== "/etc/ntfy/server.yml"
``` yaml
base-url: "https://ntfy.sh"
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
smtp-sender-user: "AKIDEADBEEFAFFE12345"
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
smtp-sender-from: "ntfy@ntfy.sh"
```
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
## E-mail publishing
To allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured,
users can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or
`myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for
statuspage.io (though these days most services also support webhooks and HTTP calls).
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
accepted (which may obviously be a spam problem).
Here's an example config (this is how it is configured for `ntfy.sh`):
=== "/etc/ntfy/server.yml"
``` yaml
smtp-server-listen: ":25"
smtp-server-domain: "ntfy.sh"
smtp-server-addr-prefix: "ntfy-"
```
In addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record)
and a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is
configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
<figure markdown>
![DNS records for incoming mail](static/img/screenshot-email-publishing-dns.png){ width=600 }
<figcaption>DNS records for incoming mail</figcaption>
</figure>
## Behind a proxy (TLS, etc.)
!!! warning
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
[rate limited](#rate-limiting) as if they are one.
It may be desirable to run ntfy behind a proxy, e.g. so you can provide TLS certificates using Let's Encrypt using certbot,
or simply because you'd like to share the ports (80/443) with other services. Whatever your reasons may be, there are a
few things to consider.
It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
Whatever your reasons may be, there are a few things to consider.
### Rate limiting
If you are running ntfy behind a proxy (e.g. nginx, HAproxy or Apache), you should set the `behind-proxy`
flag. This will instruct the [rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary
identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
=== "/etc/ntfy/config.yml"
```
=== "/etc/ntfy/server.yml"
``` yaml
# Tell ntfy to use "X-Forwarded-For" to identify visitors
behind-proxy: true
```
@@ -149,7 +208,7 @@ or the root domain:
ProxyPass / http://127.0.0.1:2586/
ProxyPassReverse / http://127.0.0.1:2586/
# Higher than the max message size of 512k
# Higher than the max message size of 4096 bytes
LimitRequestBody 102400
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
@@ -173,7 +232,7 @@ or the root domain:
ProxyPass / http://127.0.0.1:2586/
ProxyPassReverse / http://127.0.0.1:2586/
# Higher than the max message size of 512k
# Higher than the max message size of 4096 bytes
LimitRequestBody 102400
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
@@ -200,7 +259,7 @@ To configure FCM for your self-hosted instance of the ntfy server, follow these
1. Sign up for a [Firebase account](https://console.firebase.google.com/)
2. Create a Firebase app and download the key file (e.g. `myapp-firebase-adminsdk-...json`)
3. Place the key file in `/etc/ntfy`, set the `firebase-key-file` in `config.yml` accordingly and restart the ntfy server
3. Place the key file in `/etc/ntfy`, set the `firebase-key-file` in `server.yml` accordingly and restart the ntfy server
4. Build your own Android .apk following [these instructions](develop.md#android-app)
Example:
@@ -214,7 +273,7 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
## Rate limiting
!!! info
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
Otherwise all visitors are rate limited as if they are one.
Otherwise, all visitors are rate limited as if they are one.
By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload.
There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first:
@@ -235,9 +294,14 @@ request every 10s (defined by `visitor-request-limit-replenish`)
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
During normal usage, you shouldn't encounter this limit at all, and even if you burst a few requests shortly (e.g. when you
reconnect after a connection drop), it shouldn't have any effect.
Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications)
are enabled):
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails
(e.g. when you reconnect after a connection drop), it shouldn't have any effect.
## 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,
@@ -294,12 +358,13 @@ to maintain the client connection and the connection to ntfy.
```
## Config options
Each config option can be set in the config file `/etc/ntfy/config.yml` (e.g. `listen-http: :80`) or as a
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| Config option | Env variable | Format | Default | Description |
|---|---|---|---|---|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
@@ -307,42 +372,70 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 30s | 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. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 |Initial limit of e-mails per visitor |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
## Command line options
```
$ ntfy --help
$ ntfy serve --help
NAME:
ntfy - Simple pub-sub notification service
ntfy serve - Run the ntfy server
USAGE:
ntfy [OPTION..]
ntfy serve [OPTIONS..]
GLOBAL OPTIONS:
--config value, -c value config file (default: /etc/ntfy/config.yml) [$NTFY_CONFIG_FILE]
--listen-http value, -l value ip:port used to as listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--keepalive-interval value, -k value interval of keepalive messages (default: 30s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--global-topic-limit value, -T value total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, -V value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-request-limit-burst value, -B value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-replenish value, -R value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
DESCRIPTION:
Run the ntfy server and listen for incoming requests
The command will load the configuration from /etc/ntfy/server.yml. Config options can
be overridden using the command line options.
Examples:
ntfy serve # Starts server in the foreground (on port 80)
ntfy serve --listen-http :8080 # Starts server with alternate port
Try 'ntfy COMMAND --help' for more information.
ntfy v1.4.8 (7b8185c), runtime go1.17, built at 1637872539
Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0
OPTIONS:
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--keepalive-interval value, -k value interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
--smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
--smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
--global-topic-limit value, -T value total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--help, -h show help (default: false)
```

25
docs/deprecations.md Normal file
View File

@@ -0,0 +1,25 @@
# Deprecation notices
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
**removed after ~3 months** from the time they were deprecated.
## Active deprecations
### Running server via `ntfy` (instead of `ntfy serve`)
> since 2021-12-17
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
just the server.
=== "Before"
```
$ ntfy
2021/12/17 08:16:01 Listening on :80/http
```
=== "After"
```
$ ntfy serve
2021/12/17 08:16:01 Listening on :80/http
```

View File

@@ -17,7 +17,7 @@ subscribed to a topic.
## Will you know what topics exist, can you spy on me?
If you don't trust me or your messages are sensitive, run your own server. It's <a href="https://github.com/binwiederhier/ntfy">open source</a>.
That said, the logs do not contain any topic names or other details about you.
Messages are cached for the duration configured in `config.yml` (12h by default) to facilitate service restarts, message polling and to overcome
Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome
client network disruptions.
## Can I self-host it?

View File

@@ -22,14 +22,20 @@ For this guide, we'll just use `mytopic` as our topic name:
That's it. After you tap "Subscribe", the app is listening for new messages on that topic.
## Step 2: Send a message
Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT or POST. The message
is in the request body. Here's an example showing how to publish a simple message using a POST request:
Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT/POST,
or with the [ntfy CLI](install.md). The message is in the request body. Here's an example showing how to publish a
simple message using a POST request:
=== "Command line (curl)"
```
curl -d "Backup successful 😀" ntfy.sh/mytopic
```
=== "ntfy CLI"
```
ntfy publish mytopic "Backup successful 😀"
```
=== "HTTP"
``` http
POST /mytopic HTTP/1.1
@@ -52,6 +58,12 @@ is in the request body. Here's an example showing how to publish a simple messag
strings.NewReader("Backup successful 😀"))
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="Backup successful 😀".encode(encoding='utf-8'))
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -66,7 +78,7 @@ is in the request body. Here's an example showing how to publish a simple messag
This will create a notification that looks like this:
<figure markdown>
![basic notification](static/img/basic-notification.png){ width=500 }
![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }
<figcaption>Android notification</figcaption>
</figure>
@@ -76,7 +88,7 @@ That's it. You're all set. Go play and read the rest of the docs. I highly recom
Here's another video showing the entire process:
<figure>
<video controls muted autoplay loop width="650" src="static/img/overview.mp4"></video>
<video controls muted autoplay loop width="650" src="static/img/android-video-overview.mp4"></video>
<figcaption>Sending push notifications to your Android phone</figcaption>
</figure>

View File

@@ -1,18 +1,24 @@
# Install your own ntfy server
**Self-hosting your own ntfy server** is pretty straight forward. Just install the binary, package or Docker image, then
# Installing ntfy
The `ntfy` CLI allows you to [publish messages](publish.md), [subscribe to topics](subscribe/cli.md) as well as to
self-host your own ntfy server. It's all pretty straight forward. Just install the binary, package or Docker image,
configure it and run it. Just like any other software. No fuzz.
!!! info
The following steps are only required if you want to **self-host your own ntfy server**. If you just want to
[send messages using ntfy.sh](publish.md), you don't need to install anything.
The following steps are only required if you want to **self-host your own ntfy server or you want to use the ntfy CLI**.
If you just want to [send messages using ntfy.sh](publish.md), you don't need to install anything. You can just use
`curl`.
## General steps
The ntfy server comes as a statically linked binary and is shipped as tarball, deb/rpm packages and as a Docker image.
We support amd64, armv7 and arm64.
1. Install ntfy using one of the methods described below
2. Then (optionally) edit `/etc/ntfy/config.yml` (see [configuration](config.md))
3. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm).
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md]
for details).
## Binaries and packages
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
@@ -20,23 +26,23 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_x86_64.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy
sudo ./ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_armv7.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_armv7.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy
sudo ./ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_arm64.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_arm64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy
sudo ./ntfy serve
```
## Debian/Ubuntu repository
@@ -82,7 +88,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -90,7 +96,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -98,7 +104,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -108,36 +114,50 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
## Arch Linux
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
```
paru -S ntfysh-bin
```
Alternatively, run the following commands to install ntfy manually:
```
curl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv
cd ntfysh-bin
makepkg -si
```
## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
straight forward to use.
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings, you should map `/etc/ntfy`,
so you can edit `/etc/ntfy/config.yml`.
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`.
Basic usage (no cache or additional config):
```
docker run -p 80:80 -it binwiederhier/ntfy
docker run -p 80:80 -it binwiederhier/ntfy serve
```
With persistent cache (configured as command line arguments):
@@ -147,18 +167,28 @@ docker run \
-p 80:80 \
-it \
binwiederhier/ntfy \
--cache-file /var/cache/ntfy/cache.db
--cache-file /var/cache/ntfy/cache.db \
serve
```
With other config options (configured via `/etc/ntfy/config.yml`, see [configuration](config.md) for details):
With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
```bash
docker run \
-v /etc/ntfy:/etc/ntfy \
-p 80:80 \
-it \
binwiederhier/ntfy
binwiederhier/ntfy \
serve
```
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
```
FROM binwiederhier/ntfy
COPY server.yml /etc/ntfy/server.yml
ENTRYPOINT ["ntfy", "serve"]
```
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
## Go
To install via Go, simply run:
```bash

View File

@@ -1,6 +1,7 @@
# Publishing
Publishing messages can be done via HTTP PUT or POST. Topics are created on the fly by subscribing or publishing to them.
Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable.
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](install.md). Topics are created on the fly by
subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
something that's not easily guessable.
Here's an example showing how to publish a simple message using a POST request:
@@ -9,6 +10,11 @@ Here's an example showing how to publish a simple message using a POST request:
curl -d "Backup successful 😀" ntfy.sh/mytopic
```
=== "ntfy CLI"
```
ntfy publish mytopic "Backup successful 😀"
```
=== "HTTP"
``` http
POST /mytopic HTTP/1.1
@@ -30,6 +36,12 @@ Here's an example showing how to publish a simple message using a POST request:
strings.NewReader("Backup successful 😀"))
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="Backup successful 😀".encode(encoding='utf-8'))
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -44,7 +56,7 @@ Here's an example showing how to publish a simple message using a POST request:
If you have the [Android app](subscribe/phone.md) installed on your phone, this will create a notification that looks like this:
<figure markdown>
![basic notification](static/img/basic-notification.png){ width=500 }
![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }
<figcaption>Android notification</figcaption>
</figure>
@@ -61,6 +73,16 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
ntfy.sh/phil_alerts
```
=== "ntfy CLI"
```
ntfy publish \
--title "Unauthorized access detected" \
--tags warning,skull \
--priority urgent \
mytopic \
"Remote access to phils-laptop detected. Act right away."
```
=== "HTTP"
``` http
POST /phil_alerts HTTP/1.1
@@ -95,6 +117,17 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/phil_alerts",
data="Remote access to phils-laptop detected. Act right away.",
headers={
"Title": "Unauthorized access detected",
"Priority": "urgent",
"Tags": "warning,skull"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([
@@ -126,6 +159,13 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
curl -H "t: Dogs are better than cats" -d "Oh my ..." ntfy.sh/controversial
```
=== "ntfy CLI"
```
ntfy publish \
-t "Dogs are better than cats" \
controversial "Oh my ..."
```
=== "HTTP"
``` http
POST /controversial HTTP/1.1
@@ -151,6 +191,13 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/controversial",
data="Oh my ...",
headers={ "Title": "Dogs are better than cats" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/controversial', false, stream_context_create([
@@ -192,6 +239,13 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
curl -H p:4 -d "A high priority message" ntfy.sh/phil_alerts
```
=== "ntfy CLI"
```
ntfy publish \
-p 5 \
phil_alerts An urgent message
```
=== "HTTP"
``` http
POST /phil_alerts HTTP/1.1
@@ -217,6 +271,13 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/phil_alerts",
data="An urgent message",
headers={ "Priority": "5" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([
@@ -289,6 +350,13 @@ them with a comma, e.g. `tag1,tag2,tag3`.
curl -H ta:dog -d "Dogs are awesome" ntfy.sh/backups
```
=== "ntfy CLI"
```
ntfy publish \
--tags=warning,mailsrv13,daily-backup \
backups "Backup of mailsrv13 failed"
```
=== "HTTP"
``` http
POST /backups HTTP/1.1
@@ -314,6 +382,13 @@ them with a comma, e.g. `tag1,tag2,tag3`.
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/backups",
data="Backup of mailsrv13 failed",
headers={ "Tags": "warning,mailsrv13,daily-backup" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
@@ -332,6 +407,312 @@ them with a comma, e.g. `tag1,tag2,tag3`.
<figcaption>Detail view of notifications with tags</figcaption>
</figure>
## Scheduled delivery
You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
reminders or even to execute commands at a later date (if your subscriber acts on messages).
Usage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`,
`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`,
`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`,
[and more](https://github.com/olebedev/when)).
As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can currently
not be configured otherwise ([let me know](https://github.com/binwiederhier/ntfy/issues) if you'd like to change
these limits).
For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours
after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled
to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally,
[turning off server-side caching](#message-caching) is not possible in combination with this feature.
=== "Command line (curl)"
```
curl -H "At: tomorrow, 10am" -d "Good morning" ntfy.sh/hello
curl -H "In: 30min" -d "It's 30 minutes later now" ntfy.sh/reminder
curl -H "Delay: 1639194738" -d "Unix timestamps are awesome" ntfy.sh/itsaunixsystem
```
=== "ntfy CLI"
```
ntfy publish \
--at="tomorrow, 10am" \
hello "Good morning"
```
=== "HTTP"
``` http
POST /hello HTTP/1.1
Host: ntfy.sh
At: tomorrow, 10am
Good morning
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/hello', {
method: 'POST',
body: 'Good morning',
headers: { 'At': 'tomorrow, 10am' }
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning"))
req.Header.Set("At", "tomorrow, 10am")
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/hello",
data="Good morning",
headers={ "At": "tomorrow, 10am" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
"At: tomorrow, 10am",
'content' => 'Good morning'
]
]));
```
Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**):
<table class="remove-md-box"><tr>
<td>
<table><thead><tr><th><code>Delay/At/In</code> header</th><th>Message will be delivered at</th><th>Explanation</th></tr></thead><tbody>
<tr><td><code>30m</code></td><td>12/10/2021, 9:<b>30</b>am</td><td>30 minutes from now</td></tr>
<tr><td><code>2 hours</code></td><td>12/10/2021, <b>11:30</b>am</td><td>2 hours from now</td></tr>
<tr><td><code>1 day</code></td><td>12/<b>11</b>/2021, 9am</td><td>24 hours from now</td></tr>
<tr><td><code>10am</code></td><td>12/10/2021, <b>10am</b></td><td>Today at 10am (same day, because it's only 9am)</td></tr>
<tr><td><code>8am</code></td><td>12/<b>11</b>/2021, <b>8am</b></td><td>Tomorrow at 8am (because it's 9am already)</td></tr>
<tr><td><code>1639152000</code></td><td>12/10/2021, 11am (EST)</td><td> Today at 11am (EST)</td></tr>
</tbody></table>
</td>
</tr></table>
## Webhooks (Send via GET)
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without
any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are
also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all
[supported parameters and headers](#list-of-all-parameters) for details.
For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message
(aka trigger the webhook):
=== "Command line (curl)"
```
curl ntfy.sh/mywebhook/trigger
```
=== "ntfy CLI"
```
ntfy trigger mywebhook
```
=== "HTTP"
``` http
GET /mywebhook/trigger HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/mywebhook/trigger')
```
=== "Go"
``` go
http.Get("https://ntfy.sh/mywebhook/trigger")
```
=== "Python"
``` python
requests.get("https://ntfy.sh/mywebhook/trigger")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mywebhook/trigger');
```
To add a custom message, simply append the `message=` URL parameter. And of course you can set the
[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well.
For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters).
Here's an example with a custom message, tags and a priority:
=== "Command line (curl)"
```
curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
```
=== "ntfy CLI"
```
ntfy publish \
-p 5 --tags=warning,skull \
mywebhook "Webhook triggered"
```
=== "HTTP"
``` http
GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull')
```
=== "Go"
``` go
http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
```
=== "Python"
``` python
requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
```
## E-mail notifications
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
you'd like to persist longer, or to blast-notify yourself on all possible channels.
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
Only one e-mail address is supported.
Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
that, your IP address appears in the e-mail body. This is to prevent abuse.
=== "Command line (curl)"
```
curl \
-H "Email: phil@example.com" \
-H "Tags: warning,skull,backup-host,ssh-login" \
-H "Priority: high" \
-d "Unknown login from 5.31.23.83 to backups.example.com" \
ntfy.sh/alerts
curl -H "Email: phil@example.com" -d "You've Got Mail"
curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com"
```
=== "ntfy CLI"
```
ntfy publish \
--email=phil@example.com \
--tags=warning,skull,backup-host,ssh-login \
--priority=high \
alerts "Unknown login from 5.31.23.83 to backups.example.com"
```
=== "HTTP"
``` http
POST /alerts HTTP/1.1
Host: ntfy.sh
Email: phil@example.com
Tags: warning,skull,backup-host,ssh-login
Priority: high
Unknown login from 5.31.23.83 to backups.example.com
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/alerts', {
method: 'POST',
body: "Unknown login from 5.31.23.83 to backups.example.com",
headers: {
'Email': 'phil@example.com',
'Tags': 'warning,skull,backup-host,ssh-login',
'Priority': 'high'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com"))
req.Header.Set("Email", "phil@example.com")
req.Header.Set("Tags", "warning,skull,backup-host,ssh-login")
req.Header.Set("Priority", "high")
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/alerts",
data="Unknown login from 5.31.23.83 to backups.example.com",
headers={
"Email": "phil@example.com",
"Tags": "warning,skull,backup-host,ssh-login",
"Priority": "high"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
"Email: phil@example.com\r\n" .
"Tags: warning,skull,backup-host,ssh-login\r\n" .
"Priority: high",
'content' => 'Unknown login from 5.31.23.83 to backups.example.com'
]
]));
```
Here's what that looks like in Google Mail:
<figure markdown>
![e-mail notification](static/img/screenshot-email.png){ width=600 }
<figcaption>E-mail notification</figcaption>
</figure>
## E-mail publishing
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
Depending on the [server configuration](config.md#e-mail-publishing), the e-mail address format can have a prefix to
prevent spam on topics. For ntfy.sh, the prefix is configured to `ntfy-`, meaning that the general e-mail address
format is:
```
ntfy-$topic@ntfy.sh
```
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)):
<figure markdown>
![e-mail publishing](static/img/screenshot-email-publishing-gmail.png){ width=500 }
<figcaption>Publishing a message via e-mail</figcaption>
</figure>
## Advanced features
### Message caching
@@ -346,8 +727,8 @@ client-side network disruptions, but arguably this feature also may raise privac
To avoid messages being cached server-side entirely, you can set `X-Cache` header (or its alias: `Cache`) to `no`.
This will make sure that your message is not cached on the server, even if server-side caching is enabled. Messages
are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fetching-cached-messages) and
[`poll=1`](subscribe/api.md#polling) won't return the message anymore.
are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fetch-cached-messages) and
[`poll=1`](subscribe/api.md#poll-for-messages) won't return the message anymore.
=== "Command line (curl)"
```
@@ -355,6 +736,13 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
curl -H "Cache: no" -d "This message won't be stored server-side" ntfy.sh/mytopic
```
=== "ntfy CLI"
```
ntfy publish \
--no-cache \
mytopic "This message won't be stored server-side"
```
=== "HTTP"
``` http
POST /mytopic HTTP/1.1
@@ -380,6 +768,13 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="This message won't be stored server-side",
headers={ "Cache": "no" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -393,7 +788,7 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
]));
```
### Firebase
### Disable Firebase
!!! info
If `Firebase: no` is used and [instant delivery](subscribe/phone.md#instant-delivery) isn't enabled in the Android
app (Google Play variant only), **message delivery will be significantly delayed (up to 15 minutes)**. To overcome
@@ -413,6 +808,13 @@ to `no`. This will instruct the server not to forward messages to Firebase.
curl -H "Firebase: no" -d "This message won't be forwarded to FCM" ntfy.sh/mytopic
```
=== "ntfy CLI"
```
ntfy publish \
--no-firebase \
mytopic "This message won't be forwarded to FCM"
```
=== "HTTP"
``` http
POST /mytopic HTTP/1.1
@@ -438,6 +840,13 @@ to `no`. This will instruct the server not to forward messages to Firebase.
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="This message won't be forwarded to FCM",
headers={ "Firebase": "no" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -450,3 +859,30 @@ to `no`. This will instruct the server not to forward messages to Firebase.
]
]));
```
## Limitations
There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into,
but just in case, let's list them all:
| Limit | Description |
|---|---|
| **Message length** | Each message can be up to 4096 bytes long. Longer messages are truncated. |
| **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
| **E-mails** | By default, the server is configured to allow sending 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
| **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Total number of topics** | By default, the server is configured to allow 5,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**,
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
| Parameter | Aliases (case-insensitive) | Description |
|---|---|---|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |

View File

@@ -8,6 +8,16 @@
width: unset !important;
}
.md-typeset h4 {
font-weight: 500 !important;
margin: 0 !important;
font-size: 1.1em !important;
}
.admonition {
font-size: .74rem !important;
}
article {
padding-bottom: 50px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/static/img/screenshot-email.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,7 +1,7 @@
# Subscribe via API
You can create and subscribe to a topic either in the [web UI](web.md), via the [phone app](phone.md), or in your own
app or script by subscribing the API. This page describes how to subscribe via API. You may also want to check out the
page that describes how to [publish messages](../publish.md).
You can create and subscribe to a topic in the [web UI](web.md), via the [phone app](phone.md), via the [ntfy CLI](cli.md),
or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to
check out the page that describes how to [publish messages](../publish.md).
The subscription API relies on a simple HTTP GET request with a streaming HTTP response, i.e **you open a GET request and
the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which
@@ -26,6 +26,13 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
...
```
=== "ntfy CLI"
```
$ ntfy subcribe disk-alerts
{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Disk full"}
...
```
=== "HTTP"
``` http
GET /disk-alerts/json HTTP/1.1
@@ -54,6 +61,14 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
}
```
=== "Python"
``` python
resp = requests.get("https://ntfy.sh/disk-alerts/json", stream=True)
for line in resp.iter_lines():
if line:
print(line)
```
=== "PHP"
``` php-inline
$fp = fopen('https://ntfy.sh/disk-alerts/json', 'r');
@@ -150,6 +165,14 @@ format. Keepalive messages are sent as empty lines.
}
```
=== "Python"
``` python
resp = requests.get("https://ntfy.sh/disk-alerts/raw", stream=True)
for line in resp.iter_lines():
if line:
print(line)
```
=== "PHP"
``` php-inline
$fp = fopen('https://ntfy.sh/disk-alerts/raw', 'r');
@@ -161,6 +184,69 @@ format. Keepalive messages are sent as empty lines.
fclose($fp);
```
## Advanced features
### Poll for messages
You can also just poll for messages if you don't like the long-standing connection using the `poll=1`
query parameter. The connection will end after all available messages have been read. This parameter can be
combined with `since=` (defaults to `since=all`).
```
curl -s "ntfy.sh/mytopic/json?poll=1"
```
### Fetch cached messages
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
the `since=` query parameter. It takes either a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`)
or `all` (all cached messages).
```
curl -s "ntfy.sh/mytopic/json?since=10m"
```
### Fetch scheduled messages
Messages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically
returned when subscribing via the API, which makes sense, because after all, the messages have technically not been
delivered yet. To also return scheduled messages from the API, you can use the `scheduled=1` (alias: `sched=1`)
parameter (makes most sense with the `poll=1` parameter):
```
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
```
### Filter messages
You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
```
$ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
{"id":"0TIkJpBcxR","time":1640122627,"event":"open","topic":"alerts"}
{"id":"X3Uzz9O1sM","time":1640122674,"event":"message","topic":"alerts","priority":4,
"tags":["error", "zfs-error"], "message":"ZFS pool corruption detected"}
```
Available filters (all case-insensitive):
| Filter variable | Alias | Example | Description |
|---|---|---|---|
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
### Subscribe to multiple topics
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
in the URL. This allows you to reduce the number of connections you have to maintain:
```
$ curl -s ntfy.sh/mytopic1,mytopic2/json
{"id":"0OkXIryH3H","time":1637182619,"event":"open","topic":"mytopic1,mytopic2,mytopic3"}
{"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"}
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
```
## JSON message format
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
format of the message. It's very straight forward:
@@ -181,17 +267,17 @@ Here's an example for each message type:
=== "Notification message"
``` json
{
"id": "wze9zgqK41",
"time": 1638542110,
"event": "message",
"topic": "phil_alerts",
"priority": 5,
"tags": [
"warning",
"skull"
],
"title": "Unauthorized access detected",
"message": "Remote access to phils-laptop detected. Act right away."
"id": "wze9zgqK41",
"time": 1638542110,
"event": "message",
"topic": "phil_alerts",
"priority": 5,
"tags": [
"warning",
"skull"
],
"title": "Unauthorized access detected",
"message": "Remote access to phils-laptop detected. Act right away."
}
```
@@ -199,62 +285,43 @@ Here's an example for each message type:
=== "Notification message (minimal)"
``` json
{
"id": "wze9zgqK41",
"time": 1638542110,
"event": "message",
"topic": "phil_alerts",
"message": "Remote access to phils-laptop detected. Act right away."
"id": "wze9zgqK41",
"time": 1638542110,
"event": "message",
"topic": "phil_alerts",
"message": "Remote access to phils-laptop detected. Act right away."
}
```
=== "Open message"
``` json
{
"id": "2pgIAaGrQ8",
"time": 1638542215,
"event": "open",
"topic": "phil_alerts"
"id": "2pgIAaGrQ8",
"time": 1638542215,
"event": "open",
"topic": "phil_alerts"
}
```
=== "Keepalive message"
=== "Keepalive message"
``` json
{
"id": "371sevb0pD",
"time": 1638542275,
"event": "keepalive",
"topic": "phil_alerts"
"id": "371sevb0pD",
"time": 1638542275,
"event": "keepalive",
"topic": "phil_alerts"
}
```
## Advanced features
## List of all parameters
The following is a list of all parameters that can be passed when subscribing to a message. Parameter names are **case-insensitive**,
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
### Fetching cached messages
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
the `since=` query parameter. It takes either a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`)
or `all` (all cached messages).
```
curl -s "ntfy.sh/mytopic/json?since=10m"
```
### Polling
You can also just poll for messages if you don't like the long-standing connection using the `poll=1`
query parameter. The connection will end after all available messages have been read. This parameter can be
combined with `since=` (defaults to `since=all`).
```
curl -s "ntfy.sh/mytopic/json?poll=1"
```
### Subscribing to multiple topics
It's possible to subscribe to multiple topics in one HTTP call by providing a
comma-separated list of topics in the URL. This allows you to reduce the number of connections you have to maintain:
```
$ curl -s ntfy.sh/mytopic1,mytopic2/json
{"id":"0OkXIryH3H","time":1637182619,"event":"open","topic":"mytopic1,mytopic2,mytopic3"}
{"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"}
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
```
| Parameter | Aliases (case-insensitive) | Description |
|---|---|---|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |

198
docs/subscribe/cli.md Normal file
View File

@@ -0,0 +1,198 @@
# Subscribe via ntfy CLI
In addition to subscribing via the [web UI](web.md), the [phone app](phone.md), or the [API](api.md), you can subscribe
to topics via the ntfy CLI. The CLI is included in the same `ntfy` binary that can be used to [self-host a server](../install.md).
!!! info
The **ntfy CLI is not required to send or receive messages**. You can instead [send messages with curl](../publish.md),
and even use it to [subscribe to topics](api.md). It may be a little more convenient to use the ntfy CLI than writing
your own script. It all depends on the use case. 😀
## Install + configure
To install the ntfy CLI, simply **follow the steps outlined on the [install page](../install.md)**. The ntfy server and
client are the same binary, so it's all very convenient. After installing, you can (optionally) configure the client
by creating `~/.config/ntfy/client.yml` (for the non-root user), or `/etc/ntfy/client.yml` (for the root user). You
can find a [skeleton config](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml) on GitHub.
If you just want to use [ntfy.sh](https://ntfy.sh), you don't have to change anything. If you **self-host your own server**,
you may want to edit the `default-host` option:
``` yaml
# Base URL used to expand short topic names in the "ntfy publish" and "ntfy subscribe" commands.
# If you self-host a ntfy server, you'll likely want to change this.
#
default-host: https://ntfy.myhost.com
```
## Publish messages
You can send messages with the ntfy CLI using the `ntfy publish` command (or any of its aliases `pub`, `send` or
`trigger`). There are a lot of examples on the page about [publishing messages](../publish.md), but here are a few
quick ones:
=== "Simple send"
```
ntfy publish mytopic This is a message
ntfy publish mytopic "This is a message"
ntfy pub mytopic "This is a message"
```
=== "Send with title, priority, and tags"
```
ntfy publish \
--title="Thing sold on eBay" \
--priority=high \
--tags=partying_face \
mytopic \
"Somebody just bought the thing that you sell"
```
=== "Send at 8:30am"
```
ntfy pub --at=8:30am delayed_topic Laterzz
```
=== "Triggering a webhook"
```
ntfy trigger mywebhook
ntfy pub mywebhook
```
## Subscribe to topics
You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command
will either print or execute a command for every arriving message. There are a few different ways
in which the command can be run:
### Stream messages as JSON
```
ntfy subscribe TOPIC
```
If you run the command like this, it prints the JSON representation of every incoming message. This is useful
when you have a command that wants to stream-read incoming JSON messages. Unless `--poll` is passed, this command
stays open forever.
```
$ ntfy sub mytopic
{"id":"nZ8PjH5oox","time":1639971913,"event":"message","topic":"mytopic","message":"hi there"}
{"id":"sekSLWTujn","time":1639972063,"event":"message","topic":"mytopic",priority:5,"message":"Oh no!"}
...
```
<figure>
<video controls muted autoplay loop width="650" src="../../static/img/cli-subscribe-video-1.mp4"></video>
<figcaption>Subscribe in JSON mode</figcaption>
</figure>
### Run command for every message
```
ntfy subscribe TOPIC COMMAND
```
If you run it like this, a COMMAND is executed for every incoming messages. Scroll down to see a list of available
environment variables. Here are a few examples:
```
ntfy sub mytopic 'notify-send "$m"'
ntfy sub topic1 /my/script.sh
ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p'
```
<figure>
<video controls muted autoplay loop width="650" src="../../static/img/cli-subscribe-video-2.webm"></video>
<figcaption>Execute command on incoming messages</figcaption>
</figure>
The message fields are passed to the command as environment variables and can be used in scripts. Note that since
these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them
in double-quotes, you should be fine:
| Variable | Aliases | Description |
|---|---|---
| `$NTFY_ID` | `$id` | Unique message ID |
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
| `$NTFY_TOPIC` | `$topic` | Topic name |
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
| `$NTFY_RAW` | `$raw` | Raw JSON message |
### Subscribe to multiple topics
```
ntfy subscribe --from-config
```
To subscribe to multiple topics at once, and run different commands for each one, you can use `ntfy subscribe --from-config`,
which will read the `subscribe` config from the config file. Please also check out the [ntfy-client systemd service](#using-the-systemd-service).
Here's an example config file that subscribes to three different topics, executing a different command for each of them:
=== "~/.config/ntfy/client.yml"
```yaml
subscribe:
- topic: echo-this
command: 'echo "Message received: $message"'
- topic: alerts
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
if:
priority: high,urgent
- topic: calc
command: 'gnome-calculator 2>/dev/null &'
- topic: print-temp
command: |
echo "You can easily run inline scripts, too."
temp="$(sensors | awk '/Pack/ { print substr($4,2,2) }')"
if [ $temp -gt 80 ]; then
echo "Warning: CPU temperature is $temp. Too high."
else
echo "CPU temperature is $temp. That's alright."
fi
```
In this example, when `ntfy subscribe --from-config` is executed:
* Messages to `echo-this` simply echos to standard out
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html)
* Messages to `calc` open the gnome calculator 😀 (*because, why not*)
* Messages to `print-temp` execute an inline script and print the CPU temperature
I hope this shows how powerful this command is. Here's a short video that demonstrates the above example:
<figure>
<video controls muted autoplay loop width="650" src="../../static/img/cli-subscribe-video-3.webm"></video>
<figcaption>Execute all the things</figcaption>
</figure>
### Using the systemd service
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started)
if you install the deb/rpm package. To configure it, simply edit `/etc/ntfy/client.yml` and run `sudo systemctl restart ntfy-client`.
!!! info
The `ntfy-client.service` runs as user `ntfy`, meaning that typical Linux permission restrictions apply. See below
for how to fix this.
If the service runs on your personal desktop machine, you may want to override the service user/group (`User=` and `Group=`), and
adjust the `DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` environment variables. This will allow you to run commands in your X session
as the primary machine user.
You can either manually override these systemd service entries with `sudo systemctl edit ntfy-client`, and add this
(assuming your user is `phil`). Don't forget to run `sudo systemctl daemon-reload` and `sudo systemctl restart ntfy-client`
after editing the service file:
=== "/etc/systemd/system/ntfy-client.service.d/override.conf"
```
[Service]
User=phil
Group=phil
Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
```
Or you can run the following script that creates this override config for you:
```
sudo sh -c 'cat > /etc/systemd/system/ntfy-client.service.d/override.conf' <<EOF
[Service]
User=$USER
Group=$USER
Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus"
EOF
sudo systemctl daemon-reload
sudo systemctl restart ntfy-client
```

View File

@@ -3,7 +3,6 @@ You can use the [ntfy Android App](https://play.google.com/store/apps/details?id
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4).
## Android
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
@@ -11,26 +10,27 @@ You can get the Android app from both [Google Play](https://play.google.com/stor
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
the F-Droid flavor does not use Firebase.
### Overview
## Overview
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
<div id="android-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-main.jpg"><img src="../../static/img/android-screenshot-main.jpg"/></a>
<a href="../../static/img/android-screenshot-detail.jpg"><img src="../../static/img/android-screenshot-detail.jpg"/></a>
<a href="../../static/img/android-screenshot-add.jpg"><img src="../../static/img/android-screenshot-add.jpg"/></a>
<a href="../../static/img/android-screenshot-add-instant.jpg"><img src="../../static/img/android-screenshot-add-instant.jpg"/></a>
<a href="../../static/img/android-screenshot-add-other.jpg"><img src="../../static/img/android-screenshot-add-other.jpg"/></a>
<a href="../../static/img/android-screenshot-main.png"><img src="../../static/img/android-screenshot-main.png"/></a>
<a href="../../static/img/android-screenshot-detail.png"><img src="../../static/img/android-screenshot-detail.png"/></a>
<a href="../../static/img/android-screenshot-pause.png"><img src="../../static/img/android-screenshot-pause.png"/></a>
<a href="../../static/img/android-screenshot-add.png"><img src="../../static/img/android-screenshot-add.png"/></a>
<a href="../../static/img/android-screenshot-add-instant.png"><img src="../../static/img/android-screenshot-add-instant.png"/></a>
<a href="../../static/img/android-screenshot-add-other.png"><img src="../../static/img/android-screenshot-add-other.png"/></a>
</div>
If those screenshots are still not enough, here's a video:
<figure>
<video controls muted autoplay loop width="650" src="../../static/img/overview.mp4"></video>
<video controls muted autoplay loop width="650" src="../../static/img/android-video-overview.mp4"></video>
<figcaption>Sending push notifications to your Android phone</figcaption>
</figure>
### Message priority
## Message priority
When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
@@ -50,7 +50,7 @@ the settings (and custom sounds or vibration) for each of the priorities:
<figcaption>Per-priority sound/vibration settings</figcaption>
</figure>
### Instant delivery
## Instant delivery
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
you'll see as a permanent notification that looks like this:
@@ -69,8 +69,8 @@ To do so, long-press on the foreground notification (screenshot above) and navig
<figcaption>Turning off the persistent instant delivery notification</figcaption>
</figure>
### Limitations without instant delivery
Without instant delivery, **messages may arrive with a significant delay** (sometimes many minutes, or even hours later). If you've ever picked up your phone and
**Limitations without instant delivery**: Without instant delivery, **messages may arrive with a significant delay**
(sometimes many minutes, or even hours later). If you've ever picked up your phone and
suddenly had 10 messages that were sent long before you know what I'm talking about.
The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging). FCM is the
@@ -80,6 +80,99 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
## Integrations
### UnifiedPush
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
To use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/).
That's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md)
to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
<div id="unifiedpush-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"><img src="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"/></a>
<a href="../../static/img/android-screenshot-unifiedpush-subscription.jpg"><img src="../../static/img/android-screenshot-unifiedpush-subscription.jpg"/></a>
<a href="../../static/img/android-screenshot-unifiedpush-settings.jpg"><img src="../../static/img/android-screenshot-unifiedpush-settings.jpg"/></a>
</div>
### Automation apps
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
**react to incoming messages**, as well as **send messages**.
#### React to incoming messages
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
broadcasts is supported:
<div id="integration-screenshots-receive" class="screenshots">
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
<a href="../../static/img/android-screenshot-tasker-action-edit.png"><img src="../../static/img/android-screenshot-tasker-action-edit.png"/></a>
</div>
For MacroDroid, be sure to type in the package name `io.heckel.ntfy`, otherwise intents may be silently swallowed.
If you're using topics to drive automation, you'll likely want to mute the topic in the ntfy app. This will prevent
notification popups:
<figure markdown>
![muted subscription](../static/img/android-screenshot-muted.png){ width=500 }
<figcaption>Muting notifications to prevent popups</figcaption>
</figure>
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
| Extra name | Type | Example | Description |
|---|---|---|---|
| `id` | *string* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
| `base_url` | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
| `topic` ❤️ | *string* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
| `muted` | *bool* | `true` | Indicates whether the subscription was muted in the app |
| `muted_str` | *string (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
| `time` | *int* | `1635528741` | Message date time, as Unix time stamp |
| `title` | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
| `message` ❤️ | *string* | `Some message` | Message body; **this is likely what you're interested in** |
| `tags` | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
#### Send messages using intents
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can
broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP
POST request to [publish a message](../publish.md). This is primarily useful for apps that do not support HTTP POST/PUT
(like MacroDroid). In Tasker, you can simply use the "HTTP Request" action, which is a little easier and also works if
ntfy is not installed.
Here's what that looks like:
<div id="integration-screenshots-send" class="screenshots">
<a href="../../static/img/android-screenshot-macrodroid-send-macro.png"><img src="../../static/img/android-screenshot-macrodroid-send-macro.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-send-action.png"><img src="../../static/img/android-screenshot-macrodroid-send-action.png"/></a>
<a href="../../static/img/android-screenshot-tasker-profile-send.png"><img src="../../static/img/android-screenshot-tasker-profile-send.png"/></a>
<a href="../../static/img/android-screenshot-tasker-task-edit-post.png"><img src="../../static/img/android-screenshot-tasker-task-edit-post.png"/></a>
<a href="../../static/img/android-screenshot-tasker-action-http-post.png"><img src="../../static/img/android-screenshot-tasker-action-http-post.png"/></a>
</div>
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
| Extra name | Required | Type | Example | Description |
|---|---|---|---|---|
| `base_url` | - | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
| `topic` ❤️ | ✔ | *string* | `mytopic` | Topic name; **you must set this** |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
| `message` ❤️ | ✔ | *string* | `Some message` | Message body; **you must set this** |
| `tags` | - | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `priority` | - | *string or int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
## iPhone/iOS
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
for ntfy, but it's in the works. You can track the status on GitHub.

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
import requests
resp = requests.get("https://ntfy.sh/mytopic/trigger",
data="Backup successful 😀".encode(encoding='utf-8'),
headers={
"Priority": "high",
"Tags": "warning,skull",
"Title": "Hello there"
})
resp.raise_for_status()

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python3
import requests
resp = requests.get("https://ntfy.sh/mytopic/json", stream=True)
for line in resp.iter_lines():
if line:
print(line)

17
go.mod
View File

@@ -8,38 +8,43 @@ require (
firebase.google.com/go v3.13.0+incompatible
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/mattn/go-sqlite3 v1.14.9
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.3.0
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.61.0
gopkg.in/yaml.v2 v2.4.0 // indirect
google.golang.org/api v0.63.0
gopkg.in/yaml.v2 v2.4.0
)
require (
cloud.google.com/go v0.99.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 // indirect
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20211206220100-3cb06788ce7f // indirect
google.golang.org/grpc v1.42.0 // indirect
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
google.golang.org/grpc v1.43.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

29
go.sum
View File

@@ -51,6 +51,8 @@ cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMju
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
@@ -84,8 +86,13 @@ github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWH
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
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=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
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=
@@ -198,7 +205,11 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -213,6 +224,7 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -390,8 +402,9 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -495,8 +508,9 @@ google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqiv
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.61.0 h1:TXXKS1slM3b2bZNJwD5DV/Tp6/M2cLzLOLh9PjDhrw8=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0 h1:n2bqqK895ygnBpdPDYetfy23K7fJ22wsrZKCyfuRkkA=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
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.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -567,8 +581,8 @@ google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206220100-3cb06788ce7f h1:QH7+Ym+7e2XV1dZIHapkXoeqHyNaCzn6MNp3JBaYYUc=
google.golang.org/genproto v0.0.0-20211206220100-3cb06788ce7f/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -594,8 +608,9 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

View File

@@ -16,10 +16,13 @@ var (
func main() {
cli.AppHelpTemplate += fmt.Sprintf(`
Try 'ntfy COMMAND --help' for more information.
Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.
To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.
If you want to chat, simply join the Discord server: https://discord.gg/cT7ECsZj9w.
ntfy %s (%s), runtime %s, built at %s
Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0
Copyright (C) 2021 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
`, version, commit[:7], runtime.Version(), date)
app := cmd.New()

View File

@@ -1,11 +1,11 @@
site_dir: server/docs
site_name: ntfy
site_url: https://ntfy.sh
site_description: simple HTTP-based pub-sub
site_description: Send push notifications to your phone via PUT/POST
copyright: Made with ❤️ by Philipp C. Heckel
repo_name: binwiederhier/ntfy
repo_url: https://github.com/binwiederhier/ntfy
edit_uri: edit/main/docs/
edit_uri: blob/main/docs/
theme:
name: material
@@ -31,7 +31,6 @@ theme:
- search.highlight
- search.share
- navigation.sections
# - navigation.instant
- toc.integrate
- content.tabs.link
extra:
@@ -75,6 +74,7 @@ nav:
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web UI": subscribe/web.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":
- "Installation": install.md
@@ -83,6 +83,7 @@ nav:
- "FAQs": faq.md
- "Examples": examples.md
- "Emojis 🥳 🎉": emojis.md
- "Deprecation notices": deprecations.md
- "Development": develop.md
- "Privacy policy": privacy.md

View File

@@ -13,7 +13,7 @@ if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then
chmod 700 /var/cache/ntfy
# Hack to change permissions on cache file
configfile="/etc/ntfy/config.yml"
configfile="/etc/ntfy/server.yml"
if [ -f "$configfile" ]; then
cachefile="$(cat "$configfile" | perl -n -e'/^\s*cache-file: ["'"'"']?([^"'"'"']+)["'"'"']?/ && print $1')" # Oh my, see #47
if [ -n "$cachefile" ]; then
@@ -22,7 +22,7 @@ if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then
fi
fi
# Restart service
# Restart services
systemctl --system daemon-reload >/dev/null || true
if systemctl is-active -q ntfy.service; then
echo "Restarting ntfy.service ..."
@@ -32,4 +32,12 @@ if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then
systemctl restart ntfy.service >/dev/null || true
fi
fi
if systemctl is-active -q ntfy-client.service; then
echo "Restarting ntfy-client.service ..."
if [ -x /usr/bin/deb-systemd-invoke ]; then
deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true
else
systemctl restart ntfy-client.service >/dev/null || true
fi
fi
fi

View File

@@ -4,7 +4,7 @@ set -e
# Delete the config if package is purged
if [ "$1" = "purge" ]; then
id ntfy >/dev/null 2>&1 && userdel ntfy
rm -f /etc/ntfy/config.yml
rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml
rmdir /etc/ntfy || true
fi

11
scripts/preinst.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
if [ "$1" = "install" ] || [ "$1" = "upgrade" ]; then
# Migration of old to new config file name
oldconfigfile="/etc/ntfy/config.yml"
configfile="/etc/ntfy/server.yml"
if [ -f "$oldconfigfile" ] && [ ! -f "$configfile" ]; then
mv "$oldconfigfile" "$configfile" || true
fi
fi

View File

@@ -14,8 +14,10 @@ var (
// i.e. message structs with the Event messageEvent.
type cache interface {
AddMessage(m *message) error
Messages(topic string, since sinceTime) ([]*message, error)
Messages(topic string, since sinceTime, scheduled bool) ([]*message, error)
MessagesDue() ([]*message, error)
MessageCount(topic string) (int, error)
Topics() (map[string]*topic, error)
Prune(olderThan time.Time) error
MarkPublished(m *message) error
}

View File

@@ -1,14 +1,16 @@
package server
import (
"sort"
"sync"
"time"
)
type memCache struct {
messages map[string][]*message
nop bool
mu sync.Mutex
messages map[string][]*message
scheduled map[string]*message // Message ID -> message
nop bool
mu sync.Mutex
}
var _ cache = (*memCache)(nil)
@@ -16,8 +18,9 @@ var _ cache = (*memCache)(nil)
// newMemCache creates an in-memory cache
func newMemCache() *memCache {
return &memCache{
messages: make(map[string][]*message),
nop: false,
messages: make(map[string][]*message),
scheduled: make(map[string]*message),
nop: false,
}
}
@@ -25,77 +28,109 @@ func newMemCache() *memCache {
// it is always empty and can be used if caching is entirely disabled
func newNopCache() *memCache {
return &memCache{
messages: make(map[string][]*message),
nop: true,
messages: make(map[string][]*message),
scheduled: make(map[string]*message),
nop: true,
}
}
func (s *memCache) AddMessage(m *message) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.nop {
func (c *memCache) AddMessage(m *message) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.nop {
return nil
}
if m.Event != messageEvent {
return errUnexpectedMessageType
}
if _, ok := s.messages[m.Topic]; !ok {
s.messages[m.Topic] = make([]*message, 0)
if _, ok := c.messages[m.Topic]; !ok {
c.messages[m.Topic] = make([]*message, 0)
}
s.messages[m.Topic] = append(s.messages[m.Topic], m)
delayed := m.Time > time.Now().Unix()
if delayed {
c.scheduled[m.ID] = m
}
c.messages[m.Topic] = append(c.messages[m.Topic], m)
return nil
}
func (s *memCache) Messages(topic string, since sinceTime) ([]*message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.messages[topic]; !ok || since.IsNone() {
func (c *memCache) Messages(topic string, since sinceTime, scheduled bool) ([]*message, error) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.messages[topic]; !ok || since.IsNone() {
return make([]*message, 0), nil
}
messages := make([]*message, 0) // copy!
for _, m := range s.messages[topic] {
msgTime := time.Unix(m.Time, 0)
if msgTime == since.Time() || msgTime.After(since.Time()) {
messages := make([]*message, 0)
for _, m := range c.messages[topic] {
_, messageScheduled := c.scheduled[m.ID]
include := m.Time >= since.Time().Unix() && (!messageScheduled || scheduled)
if include {
messages = append(messages, m)
}
}
sort.Slice(messages, func(i, j int) bool {
return messages[i].Time < messages[j].Time
})
return messages, nil
}
func (s *memCache) MessageCount(topic string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.messages[topic]; !ok {
return 0, nil
func (c *memCache) MessagesDue() ([]*message, error) {
c.mu.Lock()
defer c.mu.Unlock()
messages := make([]*message, 0)
for _, m := range c.scheduled {
due := time.Now().Unix() >= m.Time
if due {
messages = append(messages, m)
}
}
return len(s.messages[topic]), nil
sort.Slice(messages, func(i, j int) bool {
return messages[i].Time < messages[j].Time
})
return messages, nil
}
func (s *memCache) Topics() (map[string]*topic, error) {
s.mu.Lock()
defer s.mu.Unlock()
func (c *memCache) MarkPublished(m *message) error {
c.mu.Lock()
delete(c.scheduled, m.ID)
c.mu.Unlock()
return nil
}
func (c *memCache) MessageCount(topic string) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.messages[topic]; !ok {
return 0, nil
}
return len(c.messages[topic]), nil
}
func (c *memCache) Topics() (map[string]*topic, error) {
c.mu.Lock()
defer c.mu.Unlock()
topics := make(map[string]*topic)
for topic := range s.messages {
for topic := range c.messages {
topics[topic] = newTopic(topic)
}
return topics, nil
}
func (s *memCache) Prune(olderThan time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
for topic := range s.messages {
s.pruneTopic(topic, olderThan)
func (c *memCache) Prune(olderThan time.Time) error {
c.mu.Lock()
defer c.mu.Unlock()
for topic := range c.messages {
c.pruneTopic(topic, olderThan)
}
return nil
}
func (s *memCache) pruneTopic(topic string, olderThan time.Time) {
func (c *memCache) pruneTopic(topic string, olderThan time.Time) {
messages := make([]*message, 0)
for _, m := range s.messages[topic] {
for _, m := range c.messages[topic] {
if m.Time >= olderThan.Unix() {
messages = append(messages, m)
}
}
s.messages[topic] = messages
c.messages[topic] = messages
}

View File

@@ -9,6 +9,10 @@ func TestMemCache_Messages(t *testing.T) {
testCacheMessages(t, newMemCache())
}
func TestMemCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newMemCache())
}
func TestMemCache_Topics(t *testing.T) {
testCacheTopics(t, newMemCache())
}
@@ -25,7 +29,7 @@ func TestMemCache_NopCache(t *testing.T) {
c := newNopCache()
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
messages, err := c.Messages("mytopic", sinceAllMessages)
messages, err := c.Messages("mytopic", sinceAllMessages, false)
assert.Nil(t, err)
assert.Empty(t, messages)

View File

@@ -15,25 +15,38 @@ const (
createMessagesTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
id TEXT PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(512) NOT NULL,
title VARCHAR(256) NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags VARCHAR(256) NOT NULL
tags TEXT NOT NULL,
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT;
`
insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ?`
insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
selectMessagesSinceTimeQuery = `
SELECT id, time, message, title, priority, tags
SELECT id, time, topic, message, title, priority, tags
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time ASC
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT id, time, topic, message, title, priority, tags
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time ASC
`
selectMessagesDueQuery = `
SELECT id, time, topic, message, title, priority, tags
FROM messages
WHERE time <= ? AND published = 0
`
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE id = ?`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
@@ -41,7 +54,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 1
currentSchemaVersion = 2
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -49,16 +62,22 @@ const (
);
`
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
// 0 -> 1
migrate0To1AlterMessagesTableQuery = `
BEGIN;
ALTER TABLE messages ADD COLUMN title VARCHAR(256) NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
ALTER TABLE messages ADD COLUMN tags VARCHAR(256) NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
COMMIT;
`
// 1 -> 2
migrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
`
)
type sqliteCache struct {
@@ -84,46 +103,39 @@ func (c *sqliteCache) AddMessage(m *message) error {
if m.Event != messageEvent {
return errUnexpectedMessageType
}
_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","))
published := m.Time <= time.Now().Unix()
_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","), published)
return err
}
func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error) {
func (c *sqliteCache) Messages(topic string, since sinceTime, scheduled bool) ([]*message, error) {
if since.IsNone() {
return make([]*message, 0), nil
}
rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
var rows *sql.Rows
var err error
if scheduled {
rows, err = c.db.Query(selectMessagesSinceTimeIncludeScheduledQuery, topic, since.Time().Unix())
} else {
rows, err = c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
}
if err != nil {
return nil, err
}
defer rows.Close()
messages := make([]*message, 0)
for rows.Next() {
var timestamp int64
var priority int
var id, msg, title, tagsStr string
if err := rows.Scan(&id, &timestamp, &msg, &title, &priority, &tagsStr); err != nil {
return nil, err
}
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
messages = append(messages, &message{
ID: id,
Time: timestamp,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
})
}
if err := rows.Err(); err != nil {
return readMessages(rows)
}
func (c *sqliteCache) MessagesDue() ([]*message, error) {
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
if err != nil {
return nil, err
}
return messages, nil
return readMessages(rows)
}
func (c *sqliteCache) MarkPublished(m *message) error {
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
return err
}
func (c *sqliteCache) MessageCount(topic string) (int, error) {
@@ -169,13 +181,44 @@ func (c *sqliteCache) Prune(olderThan time.Time) error {
return err
}
func readMessages(rows *sql.Rows) ([]*message, error) {
defer rows.Close()
messages := make([]*message, 0)
for rows.Next() {
var timestamp int64
var priority int
var id, topic, msg, title, tagsStr string
if err := rows.Scan(&id, &timestamp, &topic, &msg, &title, &priority, &tagsStr); err != nil {
return nil, err
}
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
messages = append(messages, &message{
ID: id,
Time: timestamp,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return messages, nil
}
func setupDB(db *sql.DB) error {
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(selectMessagesCountQuery)
if err != nil {
return setupNewDB(db)
}
defer rowsMC.Close()
rowsMC.Close()
// If 'messages' table exists, check 'schemaVersion' table
schemaVersion := 0
@@ -188,13 +231,16 @@ func setupDB(db *sql.DB) error {
if err := rowsSV.Scan(&schemaVersion); err != nil {
return err
}
rowsSV.Close()
}
// Do migrations
if schemaVersion == currentSchemaVersion {
return nil
} else if schemaVersion == 0 {
return migrateFrom0To1(db)
return migrateFrom0(db)
} else if schemaVersion == 1 {
return migrateFrom1(db)
}
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
}
@@ -212,7 +258,7 @@ func setupNewDB(db *sql.DB) error {
return nil
}
func migrateFrom0To1(db *sql.DB) error {
func migrateFrom0(db *sql.DB) error {
log.Print("Migrating cache database schema: from 0 to 1")
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
return err
@@ -223,5 +269,16 @@ func migrateFrom0To1(db *sql.DB) error {
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
return err
}
return nil
return migrateFrom1(db)
}
func migrateFrom1(db *sql.DB) error {
log.Print("Migrating cache database schema: from 1 to 2")
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
return err
}
return nil // Update this when a new version is added
}

View File

@@ -3,16 +3,20 @@ package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"path/filepath"
"testing"
"time"
)
func TestSqliteCache_AddMessage(t *testing.T) {
func TestSqliteCache_Messages(t *testing.T) {
testCacheMessages(t, newSqliteTestCache(t))
}
func TestSqliteCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newSqliteTestCache(t))
}
func TestSqliteCache_Topics(t *testing.T) {
testCacheTopics(t, newSqliteTestCache(t))
}
@@ -25,10 +29,10 @@ func TestSqliteCache_Prune(t *testing.T) {
testCachePrune(t, newSqliteTestCache(t))
}
func TestSqliteCache_Migration_0to1(t *testing.T) {
func TestSqliteCache_Migration_From0(t *testing.T) {
filename := newSqliteTestCacheFile(t)
db, err := sql.Open("sqlite3", filename)
assert.Nil(t, err)
require.Nil(t, err)
// Create "version 0" schema
_, err = db.Exec(`
@@ -42,32 +46,91 @@ func TestSqliteCache_Migration_0to1(t *testing.T) {
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT;
`)
assert.Nil(t, err)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
assert.Nil(t, err)
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create cache to trigger migration
c := newSqliteTestCacheFromFile(t, filename)
messages, err := c.Messages("mytopic", sinceAllMessages)
assert.Nil(t, err)
assert.Equal(t, 10, len(messages))
assert.Equal(t, "some message 5", messages[5].Message)
assert.Equal(t, "", messages[5].Title)
assert.Nil(t, messages[5].Tags)
assert.Equal(t, 0, messages[5].Priority)
checkSchemaVersion(t, c.db)
rows, err := c.db.Query(`SELECT version FROM schemaVersion`)
assert.Nil(t, err)
assert.True(t, rows.Next())
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
require.Equal(t, "some message 5", messages[5].Message)
require.Equal(t, "", messages[5].Title)
require.Nil(t, messages[5].Tags)
require.Equal(t, 0, messages[5].Priority)
}
func TestSqliteCache_Migration_From1(t *testing.T) {
filename := newSqliteTestCacheFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 1" schema
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(512) NOT NULL,
title VARCHAR(256) NOT NULL,
priority INT NOT NULL,
tags VARCHAR(256) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
`)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create cache to trigger migration
c := newSqliteTestCacheFromFile(t, filename)
checkSchemaVersion(t, c.db)
// Add delayed message
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
require.Nil(t, c.AddMessage(delayedMessage))
// 10, not 11!
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
// 11!
messages, err = c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 11, len(messages))
}
func checkSchemaVersion(t *testing.T, db *sql.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)
require.True(t, rows.Next())
var schemaVersion int
assert.Nil(t, rows.Scan(&schemaVersion))
assert.Equal(t, 1, schemaVersion)
require.Nil(t, rows.Scan(&schemaVersion))
require.Equal(t, currentSchemaVersion, schemaVersion)
require.Nil(t, rows.Close())
}
func newSqliteTestCache(t *testing.T) *sqliteCache {

View File

@@ -27,7 +27,7 @@ func testCacheMessages(t *testing.T, c cache) {
assert.Equal(t, 2, count)
// mytopic: since all
messages, _ := c.Messages("mytopic", sinceAllMessages)
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
assert.Equal(t, 2, len(messages))
assert.Equal(t, "my message", messages[0].Message)
assert.Equal(t, "mytopic", messages[0].Topic)
@@ -38,11 +38,11 @@ func testCacheMessages(t *testing.T, c cache) {
assert.Equal(t, "my other message", messages[1].Message)
// mytopic: since none
messages, _ = c.Messages("mytopic", sinceNoMessages)
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
assert.Empty(t, messages)
// mytopic: since 2
messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)))
messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
assert.Equal(t, 1, len(messages))
assert.Equal(t, "my other message", messages[0].Message)
@@ -52,7 +52,7 @@ func testCacheMessages(t *testing.T, c cache) {
assert.Equal(t, 1, count)
// example: since all
messages, _ = c.Messages("example", sinceAllMessages)
messages, _ = c.Messages("example", sinceAllMessages, false)
assert.Equal(t, "my example message", messages[0].Message)
// non-existing: count
@@ -61,7 +61,7 @@ func testCacheMessages(t *testing.T, c cache) {
assert.Equal(t, 0, count)
// non-existing: since all
messages, _ = c.Messages("doesnotexist", sinceAllMessages)
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
assert.Empty(t, messages)
}
@@ -103,7 +103,7 @@ func testCachePrune(t *testing.T, c cache) {
assert.Nil(t, err)
assert.Equal(t, 0, count)
messages, err := c.Messages("mytopic", sinceAllMessages)
messages, err := c.Messages("mytopic", sinceAllMessages, false)
assert.Nil(t, err)
assert.Equal(t, 1, len(messages))
assert.Equal(t, "my other message", messages[0].Message)
@@ -116,8 +116,34 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
m.Title = "some title"
assert.Nil(t, c.AddMessage(m))
messages, _ := c.Messages("mytopic", sinceAllMessages)
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
assert.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
assert.Equal(t, 5, messages[0].Priority)
assert.Equal(t, "some title", messages[0].Title)
}
func testCacheMessagesScheduled(t *testing.T, c cache) {
m1 := newDefaultMessage("mytopic", "message 1")
m2 := newDefaultMessage("mytopic", "message 2")
m2.Time = time.Now().Add(time.Hour).Unix()
m3 := newDefaultMessage("mytopic", "message 3")
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
m4 := newDefaultMessage("mytopic2", "message 4")
m4.Time = time.Now().Add(time.Minute).Unix()
assert.Nil(t, c.AddMessage(m1))
assert.Nil(t, c.AddMessage(m2))
assert.Nil(t, c.AddMessage(m3))
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
assert.Equal(t, 1, len(messages))
assert.Equal(t, "message 1", messages[0].Message)
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
assert.Equal(t, 3, len(messages))
assert.Equal(t, "message 1", messages[0].Message)
assert.Equal(t, "message 3", messages[1].Message) // Order!
assert.Equal(t, "message 2", messages[2].Message)
messages, _ = c.MessagesDue()
assert.Empty(t, messages)
}

93
server/config.go Normal file
View File

@@ -0,0 +1,93 @@
package server
import (
"time"
)
// Defines default config settings
const (
DefaultListenHTTP = ":80"
DefaultCacheDuration = 12 * time.Hour
DefaultKeepaliveInterval = 55 * time.Second // Not too frequently to save battery
DefaultManagerInterval = time.Minute
DefaultAtSenderInterval = 10 * time.Second
DefaultMinDelay = 10 * time.Second
DefaultMaxDelay = 3 * 24 * time.Hour
DefaultMessageLimit = 4096
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
)
// Defines all the limits
// - global topic limit: max number of topics overall
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
const (
DefaultGlobalTopicLimit = 5000
DefaultVisitorRequestLimitBurst = 60
DefaultVisitorRequestLimitReplenish = 10 * time.Second
DefaultVisitorEmailLimitBurst = 16
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorSubscriptionLimit = 30
)
// Config is the main config struct for the application. Use New to instantiate a default config struct.
type Config struct {
BaseURL string
ListenHTTP string
ListenHTTPS string
KeyFile string
CertFile string
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
KeepaliveInterval time.Duration
ManagerInterval time.Duration
AtSenderInterval time.Duration
FirebaseKeepaliveInterval time.Duration
SMTPSenderAddr string
SMTPSenderUser string
SMTPSenderPass string
SMTPSenderFrom string
SMTPServerListen string
SMTPServerDomain string
SMTPServerAddrPrefix string
MessageLimit int
MinDelay time.Duration
MaxDelay time.Duration
GlobalTopicLimit int
VisitorRequestLimitBurst int
VisitorRequestLimitReplenish time.Duration
VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration
VisitorSubscriptionLimit int
BehindProxy bool
}
// NewConfig instantiates a default new server config
func NewConfig() *Config {
return &Config{
BaseURL: "",
ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "",
KeyFile: "",
CertFile: "",
FirebaseKeyFile: "",
CacheFile: "",
CacheDuration: DefaultCacheDuration,
KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval,
MessageLimit: DefaultMessageLimit,
MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay,
AtSenderInterval: DefaultAtSenderInterval,
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
GlobalTopicLimit: DefaultGlobalTopicLimit,
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
BehindProxy: false,
}
}

13
server/config_test.go Normal file
View File

@@ -0,0 +1,13 @@
package server_test
import (
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/server"
"testing"
)
func TestConfig_New(t *testing.T) {
c := server.NewConfig()
assert.Equal(t, ":80", c.ListenHTTP)
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
}

View File

@@ -198,7 +198,7 @@
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
</code>
<p id="detailNotificationsDisallowed">
If you'd like to receive desktop notifications when new messages arrive on this topic, you have
If you'd like to receive desktop notifications when new messages arrive on this topic, you have to
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
Click the link to do so.
</p>

1
server/mailer_emoji.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@ After=network.target
[Service]
User=ntfy
Group=ntfy
ExecStart=/usr/bin/ntfy
ExecStart=/usr/bin/ntfy serve
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE
LimitNOFILE=10000

View File

@@ -3,19 +3,21 @@ package server
import (
"bytes"
"context"
"embed" // required for go:embed
"embed"
"encoding/json"
"errors"
firebase "firebase.google.com/go"
"firebase.google.com/go/messaging"
"fmt"
"github.com/emersion/go-smtp"
"google.golang.org/api/option"
"heckel.io/ntfy/config"
"heckel.io/ntfy/util"
"html/template"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
@@ -28,23 +30,36 @@ import (
// Server is the main server, providing the UI and API for ntfy
type Server struct {
config *config.Config
topics map[string]*topic
visitors map[string]*visitor
firebase subscriber
messages int64
cache cache
mu sync.Mutex
config *Config
httpServer *http.Server
httpsServer *http.Server
smtpServer *smtp.Server
smtpBackend *smtpBackend
topics map[string]*topic
visitors map[string]*visitor
firebase subscriber
mailer mailer
messages int64
cache cache
closeChan chan bool
mu sync.Mutex
}
// errHTTP is a generic HTTP error for any non-200 HTTP error
type errHTTP struct {
Code int
Status string
Code int `json:"code,omitempty"`
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
}
func (e errHTTP) Error() string {
return fmt.Sprintf("http: %s", e.Status)
return e.Message
}
func (e errHTTP) JSON() string {
b, _ := json.Marshal(&e)
return string(b)
}
type indexPage struct {
@@ -71,15 +86,13 @@ var (
sinceNoMessages = sinceTime(time.Unix(1, 0))
)
const (
messageLimit = 512
)
var (
topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
@@ -104,14 +117,32 @@ var (
docsStaticFs embed.FS
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
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"}
errHTTPTooManyRequestsLimitGlobalTopics = &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"}
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 topic: path invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
)
const (
firebaseControlTopic = "~control" // See Android if changed
emptyMessageBody = "triggered"
)
// New instantiates a new Server. It creates the cache and adds a Firebase
// subscriber (if configured).
func New(conf *config.Config) (*Server, error) {
func New(conf *Config) (*Server, error) {
var firebaseSubscriber subscriber
if conf.FirebaseKeyFile != "" {
var err error
@@ -120,6 +151,10 @@ func New(conf *config.Config) (*Server, error) {
return nil, err
}
}
var mailer mailer
if conf.SMTPSenderAddr != "" {
mailer = &smtpSender{config: conf}
}
cache, err := createCache(conf)
if err != nil {
return nil, err
@@ -132,12 +167,13 @@ func New(conf *config.Config) (*Server, error) {
config: conf,
cache: cache,
firebase: firebaseSubscriber,
mailer: mailer,
topics: topics,
visitors: make(map[string]*visitor),
}, nil
}
func createCache(conf *config.Config) (cache, error) {
func createCache(conf *Config) (cache, error) {
if conf.CacheDuration == 0 {
return newNopCache(), nil
} else if conf.CacheFile != "" {
@@ -146,7 +182,7 @@ func createCache(conf *config.Config) (cache, error) {
return newMemCache(), nil
}
func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
func createFirebaseSubscriber(conf *Config) (subscriber, error) {
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(conf.FirebaseKeyFile))
if err != nil {
return nil, err
@@ -156,9 +192,17 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
return nil, err
}
return func(m *message) error {
_, err := msg.Send(context.Background(), &messaging.Message{
Topic: m.Topic,
Data: map[string]string{
var data map[string]string // Matches https://ntfy.sh/docs/subscribe/api/#json-message-format
switch m.Event {
case keepaliveEvent, openEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
}
case messageEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
@@ -167,7 +211,11 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
"tags": strings.Join(m.Tags, ","),
"title": m.Title,
"message": m.Message,
},
}
}
_, err := msg.Send(context.Background(), &messaging.Message{
Topic: m.Topic,
Data: data,
})
return err
}, nil
@@ -176,39 +224,70 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
// a manager go routine to print stats and prune messages.
func (s *Server) Run() error {
go func() {
ticker := time.NewTicker(s.config.ManagerInterval)
for {
<-ticker.C
s.updateStatsAndExpire()
}
}()
listenStr := fmt.Sprintf("%s/http", s.config.ListenHTTP)
if s.config.ListenHTTPS != "" {
listenStr += fmt.Sprintf(" %s/https", s.config.ListenHTTPS)
}
if s.config.SMTPServerListen != "" {
listenStr += fmt.Sprintf(" %s/smtp", s.config.SMTPServerListen)
}
log.Printf("Listening on %s", listenStr)
http.HandleFunc("/", s.handle)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handle)
errChan := make(chan error)
s.mu.Lock()
s.closeChan = make(chan bool)
s.httpServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux}
go func() {
errChan <- http.ListenAndServe(s.config.ListenHTTP, nil)
errChan <- s.httpServer.ListenAndServe()
}()
if s.config.ListenHTTPS != "" {
s.httpsServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux}
go func() {
errChan <- http.ListenAndServeTLS(s.config.ListenHTTPS, s.config.CertFile, s.config.KeyFile, nil)
errChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
}()
}
if s.config.SMTPServerListen != "" {
go func() {
errChan <- s.runSMTPServer()
}()
}
s.mu.Unlock()
go s.runManager()
go s.runAtSender()
go s.runFirebaseKeepliver()
return <-errChan
}
// Stop stops HTTP (+HTTPS) server and all managers
func (s *Server) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.httpServer != nil {
s.httpServer.Close()
}
if s.httpsServer != nil {
s.httpsServer.Close()
}
if s.smtpServer != nil {
s.smtpServer.Close()
}
close(s.closeChan)
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
if err := s.handleInternal(w, r); err != nil {
if e, ok := err.(*errHTTP); ok {
s.fail(w, r, e.Code, e)
} else {
s.fail(w, r, http.StatusInternalServerError, err)
var e *errHTTP
var ok bool
if e, ok = err.(*errHTTP); !ok {
e = errHTTPInternalError
}
log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, err.Error())
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.WriteHeader(e.HTTPCode)
io.WriteString(w, e.JSON()+"\n")
}
}
@@ -225,15 +304,17 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
return s.handleDocs(w, r)
} else if r.Method == http.MethodOptions {
return s.handleOptions(w, r)
} else if r.Method == http.MethodGet && topicRegex.MatchString(r.URL.Path) {
return s.handleHome(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
} else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) {
return s.handleTopic(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeJSON)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeSSE)
} else if r.Method == http.MethodGet && rawRegex.MatchString(r.URL.Path) {
} else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeRaw)
}
return errHTTPNotFound
@@ -246,6 +327,17 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
})
}
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see PUT/POST too!
if unifiedpush {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
return err
}
return s.handleHome(w, r)
}
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request) error {
return nil
}
@@ -265,83 +357,120 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visitor) error {
t, err := s.topicFromID(r.URL.Path[1:])
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
t, err := s.topicFromPath(r.URL.Path)
if err != nil {
return err
}
reader := io.LimitReader(r.Body, messageLimit)
reader := io.LimitReader(r.Body, int64(s.config.MessageLimit))
b, err := io.ReadAll(reader)
if err != nil {
return err
}
m := newDefaultMessage(t.ID, string(b))
if m.Message == "" {
return errHTTPBadRequest
}
title, priority, tags, cache, firebase := parseHeaders(r.Header)
m.Title = title
m.Priority = priority
m.Tags = tags
if err := t.Publish(m); err != nil {
m := newDefaultMessage(t.ID, strings.TrimSpace(string(b)))
cache, firebase, email, err := s.parsePublishParams(r, m)
if err != nil {
return err
}
if s.firebase != nil && firebase {
if email != "" {
if err := v.EmailAllowed(); err != nil {
return errHTTPTooManyRequestsLimitEmails
}
}
if s.mailer == nil && email != "" {
return errHTTPBadRequestEmailDisabled
}
if m.Message == "" {
m.Message = emptyMessageBody
}
delayed := m.Time > time.Now().Unix()
if !delayed {
if err := t.Publish(m); err != nil {
return err
}
}
if s.firebase != nil && firebase && !delayed {
go func() {
if err := s.firebase(m); err != nil {
log.Printf("Unable to publish to Firebase: %v", err.Error())
}
}()
}
if s.mailer != nil && email != "" && !delayed {
go func() {
if err := s.mailer.Send(v.ip, email, m); err != nil {
log.Printf("Unable to send email: %v", err.Error())
}
}()
}
if cache {
if err := s.cache.AddMessage(m); err != nil {
return err
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(m); err != nil {
return err
}
s.mu.Lock()
s.messages++
s.mu.Unlock()
s.inc(&s.messages)
return nil
}
func parseHeaders(header http.Header) (title string, priority int, tags []string, cache bool, firebase bool) {
title = readHeader(header, "x-title", "title", "ti", "t")
priorityStr := readHeader(header, "x-priority", "priority", "prio", "p")
if priorityStr != "" {
switch strings.ToLower(priorityStr) {
case "1", "min":
priority = 1
case "2", "low":
priority = 2
case "3", "default":
priority = 3
case "4", "high":
priority = 4
case "5", "max", "urgent":
priority = 5
default:
priority = 0
}
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, err error) {
cache = readParam(r, "x-cache", "cache") != "no"
firebase = readParam(r, "x-firebase", "firebase") != "no"
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
m.Title = readParam(r, "x-title", "title", "t")
messageStr := readParam(r, "x-message", "message", "m")
if messageStr != "" {
m.Message = messageStr
}
tagsStr := readHeader(header, "x-tags", "tag", "tags", "ta")
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if err != nil {
return false, false, "", errHTTPBadRequestPriorityInvalid
}
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
if tagsStr != "" {
tags = make([]string, 0)
for _, s := range strings.Split(tagsStr, ",") {
tags = append(tags, strings.TrimSpace(s))
m.Tags = make([]string, 0)
for _, s := range util.SplitNoEmpty(tagsStr, ",") {
m.Tags = append(m.Tags, strings.TrimSpace(s))
}
}
cache = readHeader(header, "x-cache", "cache") != "no"
firebase = readHeader(header, "x-firebase", "firebase") != "no"
return title, priority, tags, cache, firebase
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, "", errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see GET too!
if unifiedpush {
firebase = false
}
return cache, firebase, email, nil
}
func readHeader(header http.Header, names ...string) string {
func readParam(r *http.Request, names ...string) string {
for _, name := range names {
value := header.Get(name)
value := r.Header.Get(name)
if value != "" {
return strings.TrimSpace(value)
}
}
for _, name := range names {
value := r.URL.Query().Get(strings.ToLower(name))
if value != "" {
return strings.TrimSpace(value)
}
@@ -385,29 +514,37 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
}
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
if err := v.AddSubscription(); err != nil {
return errHTTPTooManyRequests
if err := v.SubscriptionAllowed(); err != nil {
return errHTTPTooManyRequestsLimitSubscriptions
}
defer v.RemoveSubscription()
topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
topicIDs := strings.Split(topicsStr, ",")
topicIDs := util.SplitNoEmpty(topicsStr, ",")
topics, err := s.topicsFromIDs(topicIDs...)
if err != nil {
return err
}
since, err := parseSince(r)
poll := readParam(r, "x-poll", "poll", "po") == "1"
scheduled := readParam(r, "x-scheduled", "scheduled", "sched") == "1"
since, err := parseSince(r, poll)
if err != nil {
return err
}
messageFilter, titleFilter, priorityFilter, tagsFilter, err := parseQueryFilters(r)
if err != nil {
return err
}
var wlock sync.Mutex
poll := r.URL.Query().Has("poll")
sub := func(msg *message) error {
wlock.Lock()
defer wlock.Unlock()
if !passesQueryFilter(msg, messageFilter, titleFilter, priorityFilter, tagsFilter) {
return nil
}
m, err := encoder(msg)
if err != nil {
return err
}
wlock.Lock()
defer wlock.Unlock()
if _, err := w.Write([]byte(m)); err != nil {
return err
}
@@ -419,7 +556,7 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
if poll {
return s.sendOldMessages(topics, since, sub)
return s.sendOldMessages(topics, since, scheduled, sub)
}
subscriberIDs := make([]int, 0)
for _, t := range topics {
@@ -433,7 +570,7 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
if err := s.sendOldMessages(topics, since, sub); err != nil {
if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
return err
}
for {
@@ -449,12 +586,50 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
}
}
func (s *Server) sendOldMessages(topics []*topic, since sinceTime, sub subscriber) error {
func parseQueryFilters(r *http.Request) (messageFilter string, titleFilter string, priorityFilter []int, tagsFilter []string, err error) {
messageFilter = readParam(r, "x-message", "message", "m")
titleFilter = readParam(r, "x-title", "title", "t")
tagsFilter = util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
priorityFilter = make([]int, 0)
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
priority, err := util.ParsePriority(p)
if err != nil {
return "", "", nil, nil, err
}
priorityFilter = append(priorityFilter, priority)
}
return
}
func passesQueryFilter(msg *message, messageFilter string, titleFilter string, priorityFilter []int, tagsFilter []string) bool {
if msg.Event != messageEvent {
return true // filters only apply to messages
}
if messageFilter != "" && msg.Message != messageFilter {
return false
}
if titleFilter != "" && msg.Title != titleFilter {
return false
}
messagePriority := msg.Priority
if messagePriority == 0 {
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
}
if len(priorityFilter) > 0 && !util.InIntList(priorityFilter, messagePriority) {
return false
}
if len(tagsFilter) > 0 && !util.InStringListAll(msg.Tags, tagsFilter) {
return false
}
return true
}
func (s *Server) sendOldMessages(topics []*topic, since sinceTime, scheduled bool, sub subscriber) error {
if since.IsNone() {
return nil
}
for _, t := range topics {
messages, err := s.cache.Messages(t.ID, since)
messages, err := s.cache.Messages(t.ID, since, scheduled)
if err != nil {
return err
}
@@ -471,21 +646,22 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceTime, sub subscribe
//
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h), or
// "all" for all messages.
func parseSince(r *http.Request) (sinceTime, error) {
if !r.URL.Query().Has("since") {
if r.URL.Query().Has("poll") {
func parseSince(r *http.Request, poll bool) (sinceTime, error) {
since := readParam(r, "x-since", "since", "si")
if since == "" {
if poll {
return sinceAllMessages, nil
}
return sinceNoMessages, nil
}
if r.URL.Query().Get("since") == "all" {
if since == "all" {
return sinceAllMessages, nil
} else if s, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64); err == nil {
} else if s, err := strconv.ParseInt(since, 10, 64); err == nil {
return sinceTime(time.Unix(s, 0)), nil
} else if d, err := time.ParseDuration(r.URL.Query().Get("since")); err == nil {
} else if d, err := time.ParseDuration(since); err == nil {
return sinceTime(time.Now().Add(-1 * d)), nil
}
return sinceNoMessages, errHTTPBadRequest
return sinceNoMessages, errHTTPBadRequestSinceInvalid
}
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
@@ -494,8 +670,12 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
return nil
}
func (s *Server) topicFromID(id string) (*topic, error) {
topics, err := s.topicsFromIDs(id)
func (s *Server) topicFromPath(path string) (*topic, error) {
parts := strings.Split(path, "/")
if len(parts) < 2 {
return nil, errHTTPBadRequestTopicInvalid
}
topics, err := s.topicsFromIDs(parts[1])
if err != nil {
return nil, err
}
@@ -508,11 +688,11 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
topics := make([]*topic, 0)
for _, id := range ids {
if util.InStringList(disallowedTopics, id) {
return nil, errHTTPBadRequest
return nil, errHTTPBadRequestTopicDisallowed
}
if _, ok := s.topics[id]; !ok {
if len(s.topics) >= s.config.GlobalTopicLimit {
return nil, errHTTPTooManyRequests
return nil, errHTTPTooManyRequestsLimitGlobalTopics
}
s.topics[id] = newTopic(id)
}
@@ -521,7 +701,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
return topics, nil
}
func (s *Server) updateStatsAndExpire() {
func (s *Server) updateStatsAndPrune() {
s.mu.Lock()
defer s.mu.Unlock()
@@ -532,13 +712,13 @@ func (s *Server) updateStatsAndExpire() {
}
}
// Prune cache
// Prune message cache
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
if err := s.cache.Prune(olderThan); err != nil {
log.Printf("error pruning cache: %s", err.Error())
}
// Prune old messages, remove subscriptions without subscribers
// Prune old topics, remove subscriptions without subscribers
var subscribers, messages int
for _, t := range s.topics {
subs := t.Subscribers()
@@ -555,15 +735,117 @@ func (s *Server) updateStatsAndExpire() {
messages += msgs
}
// Mail stats
var mailSuccess, mailFailure int64
if s.smtpBackend != nil {
mailSuccess, mailFailure = s.smtpBackend.Counts()
}
// Print stats
log.Printf("Stats: %d message(s) published, %d topic(s) active, %d subscriber(s), %d message(s) buffered, %d visitor(s)",
s.messages, len(s.topics), subscribers, messages, len(s.visitors))
log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
}
func (s *Server) runSMTPServer() error {
sub := func(m *message) error {
url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message))
if err != nil {
return err
}
if m.Title != "" {
req.Header.Set("Title", m.Title)
}
rr := httptest.NewRecorder()
s.handle(rr, req)
if rr.Code != http.StatusOK {
return errors.New("error: " + rr.Body.String())
}
return nil
}
s.smtpBackend = newMailBackend(s.config, sub)
s.smtpServer = smtp.NewServer(s.smtpBackend)
s.smtpServer.Addr = s.config.SMTPServerListen
s.smtpServer.Domain = s.config.SMTPServerDomain
s.smtpServer.ReadTimeout = 10 * time.Second
s.smtpServer.WriteTimeout = 10 * time.Second
s.smtpServer.MaxMessageBytes = 1024 * 1024 // Must be much larger than message size (headers, multipart, etc.)
s.smtpServer.MaxRecipients = 1
s.smtpServer.AllowInsecureAuth = true
return s.smtpServer.ListenAndServe()
}
func (s *Server) runManager() {
for {
select {
case <-time.After(s.config.ManagerInterval):
s.updateStatsAndPrune()
case <-s.closeChan:
return
}
}
}
func (s *Server) runAtSender() {
for {
select {
case <-time.After(s.config.AtSenderInterval):
if err := s.sendDelayedMessages(); err != nil {
log.Printf("error sending scheduled messages: %s", err.Error())
}
case <-s.closeChan:
return
}
}
}
func (s *Server) runFirebaseKeepliver() {
if s.firebase == nil {
return
}
for {
select {
case <-time.After(s.config.FirebaseKeepaliveInterval):
if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
log.Printf("error sending Firebase keepalive message: %s", err.Error())
}
case <-s.closeChan:
return
}
}
}
func (s *Server) sendDelayedMessages() error {
s.mu.Lock()
defer s.mu.Unlock()
messages, err := s.cache.MessagesDue()
if err != nil {
return err
}
for _, m := range messages {
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
if ok {
if err := t.Publish(m); err != nil {
log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error())
}
if s.firebase != nil {
if err := s.firebase(m); err != nil {
log.Printf("unable to publish to Firebase: %v", err.Error())
}
}
// TODO delayed email sending
}
if err := s.cache.MarkPublished(m); err != nil {
return err
}
}
return nil
}
func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
v := s.visitor(r)
if err := v.RequestAllowed(); err != nil {
return err
return errHTTPTooManyRequestsLimitRequests
}
return handler(w, r, v)
}
@@ -583,15 +865,15 @@ func (s *Server) visitor(r *http.Request) *visitor {
}
v, exists := s.visitors[ip]
if !exists {
s.visitors[ip] = newVisitor(s.config)
s.visitors[ip] = newVisitor(s.config, ip)
return s.visitors[ip]
}
v.seen = time.Now()
v.Keepalive()
return v
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, err error) {
log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, code, err.Error())
w.WriteHeader(code)
_, _ = io.WriteString(w, fmt.Sprintf("%s\n", http.StatusText(code)))
func (s *Server) inc(counter *int64) {
s.mu.Lock()
defer s.mu.Unlock()
*counter++
}

View File

@@ -1,23 +1,19 @@
# ntfy config file
# ntfy server config file
# Listen address for the HTTP web server
# Format: <hostname>:<port>
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
# This setting is currently only used by the e-mail sending feature (outgoing mail only).
#
# base-url:
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
# set "key-file" and "cert-file". Format: <hostname>:<port>
#
# listen-http: ":80"
# Listen address for the HTTPS web server. If set, you must also set "key-file" and "cert-file".
# Format: <hostname>:<port>
#
# listen-https:
# Path to the private key file for the HTTPS web server. Not used if "listen-https" is not set.
# Format: <filename>
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
#
# key-file:
# Path to the cert file for the HTTPS web server. Not used if "listen-https" is not set.
# Format: <filename>
#
# cert-file:
# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.
@@ -42,6 +38,41 @@
#
# cache-duration: 12h
# If set, the X-Forwarded-For header is used to determine the visitor IP address
# instead of the remote address of the connection.
#
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
# as if they are one.
#
# behind-proxy: false
# 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 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-addr:
# 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.
#
# - smtp-server-listen defines the IP address and port the SMTP server will listen on, e.g. :25 or 1.2.3.4:25
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
# $topic@ntfy.sh will be accepted (which may obviously be a spam problem).
#
# smtp-server-listen:
# smtp-server-domain:
# smtp-server-addr-prefix:
# Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity.
#
@@ -69,10 +100,9 @@
# visitor-request-limit-burst: 60
# visitor-request-limit-replenish: 10s
# If set, the X-Forwarded-For header is used to determine the visitor IP address
# instead of the remote address of the connection.
# Rate limiting: Allowed emails per visitor:
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
# - visitor-email-limit-replenish is the rate at which the bucket is refilled
#
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
# as if they are one.
#
# behind-proxy: false
# visitor-email-limit-burst: 16
# visitor-email-limit-replenish: 1h

View File

@@ -4,12 +4,14 @@ import (
"bufio"
"context"
"encoding/json"
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/config"
"fmt"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
@@ -19,33 +21,33 @@ func TestServer_PublishAndPoll(t *testing.T) {
response1 := request(t, s, "PUT", "/mytopic", "my first message", nil)
msg1 := toMessage(t, response1.Body.String())
assert.NotEmpty(t, msg1.ID)
assert.Equal(t, "my first message", msg1.Message)
require.NotEmpty(t, msg1.ID)
require.Equal(t, "my first message", msg1.Message)
response2 := request(t, s, "PUT", "/mytopic", "my second\n\nmessage", nil)
msg2 := toMessage(t, response2.Body.String())
assert.NotEqual(t, msg1.ID, msg2.ID)
assert.NotEmpty(t, msg2.ID)
assert.Equal(t, "my second\n\nmessage", msg2.Message)
require.NotEqual(t, msg1.ID, msg2.ID)
require.NotEmpty(t, msg2.ID)
require.Equal(t, "my second\n\nmessage", msg2.Message)
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
assert.Equal(t, 2, len(messages))
assert.Equal(t, "my first message", messages[0].Message)
assert.Equal(t, "my second\n\nmessage", messages[1].Message)
require.Equal(t, 2, len(messages))
require.Equal(t, "my first message", messages[0].Message)
require.Equal(t, "my second\n\nmessage", messages[1].Message)
response = request(t, s, "GET", "/mytopic/sse?poll=1", "", nil)
response = request(t, s, "GET", "/mytopic/sse?poll=1&since=all", "", nil)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
assert.Equal(t, 3, len(lines))
assert.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message)
assert.Equal(t, "", lines[1])
assert.Equal(t, "my second\n\nmessage", toMessage(t, strings.TrimPrefix(lines[2], "data: ")).Message)
require.Equal(t, 3, len(lines))
require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message)
require.Equal(t, "", lines[1])
require.Equal(t, "my second\n\nmessage", toMessage(t, strings.TrimPrefix(lines[2], "data: ")).Message)
response = request(t, s, "GET", "/mytopic/raw?poll=1", "", nil)
lines = strings.Split(strings.TrimSpace(response.Body.String()), "\n")
assert.Equal(t, 2, len(lines))
assert.Equal(t, "my first message", lines[0])
assert.Equal(t, "my second message", lines[1]) // \n -> " "
require.Equal(t, 2, len(lines))
require.Equal(t, "my first message", lines[0])
require.Equal(t, "my second message", lines[1]) // \n -> " "
}
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
@@ -69,21 +71,21 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
<-doneChan
messages := toMessages(t, rr.Body.String())
assert.Equal(t, 2, len(messages))
require.Equal(t, 2, len(messages))
assert.Equal(t, openEvent, messages[0].Event)
assert.Equal(t, "mytopic", messages[0].Topic)
assert.Equal(t, "", messages[0].Message)
assert.Equal(t, "", messages[0].Title)
assert.Equal(t, 0, messages[0].Priority)
assert.Nil(t, messages[0].Tags)
require.Equal(t, openEvent, messages[0].Event)
require.Equal(t, "mytopic", messages[0].Topic)
require.Equal(t, "", messages[0].Message)
require.Equal(t, "", messages[0].Title)
require.Equal(t, 0, messages[0].Priority)
require.Nil(t, messages[0].Tags)
assert.Equal(t, keepaliveEvent, messages[1].Event)
assert.Equal(t, "mytopic", messages[1].Topic)
assert.Equal(t, "", messages[1].Message)
assert.Equal(t, "", messages[1].Title)
assert.Equal(t, 0, messages[1].Priority)
assert.Nil(t, messages[1].Tags)
require.Equal(t, keepaliveEvent, messages[1].Event)
require.Equal(t, "mytopic", messages[1].Topic)
require.Equal(t, "", messages[1].Message)
require.Equal(t, "", messages[1].Title)
require.Equal(t, 0, messages[1].Priority)
require.Nil(t, messages[1].Tags)
}
func TestServer_PublishAndSubscribe(t *testing.T) {
@@ -93,63 +95,115 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR)
publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil)
assert.Equal(t, 200, publishFirstRR.Code)
require.Equal(t, 200, publishFirstRR.Code)
publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{
"Title": " This is a title ",
"X-Tags": "tag1,tag 2, tag3",
"p": "1",
})
assert.Equal(t, 200, publishSecondRR.Code)
require.Equal(t, 200, publishSecondRR.Code)
subscribeCancel()
messages := toMessages(t, subscribeRR.Body.String())
assert.Equal(t, 3, len(messages))
assert.Equal(t, openEvent, messages[0].Event)
require.Equal(t, 3, len(messages))
require.Equal(t, openEvent, messages[0].Event)
assert.Equal(t, messageEvent, messages[1].Event)
assert.Equal(t, "mytopic", messages[1].Topic)
assert.Equal(t, "my first message", messages[1].Message)
assert.Equal(t, "", messages[1].Title)
assert.Equal(t, 0, messages[1].Priority)
assert.Nil(t, messages[1].Tags)
require.Equal(t, messageEvent, messages[1].Event)
require.Equal(t, "mytopic", messages[1].Topic)
require.Equal(t, "my first message", messages[1].Message)
require.Equal(t, "", messages[1].Title)
require.Equal(t, 0, messages[1].Priority)
require.Nil(t, messages[1].Tags)
assert.Equal(t, messageEvent, messages[2].Event)
assert.Equal(t, "mytopic", messages[2].Topic)
assert.Equal(t, "my other message", messages[2].Message)
assert.Equal(t, "This is a title", messages[2].Title)
assert.Equal(t, 1, messages[2].Priority)
assert.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags)
require.Equal(t, messageEvent, messages[2].Event)
require.Equal(t, "mytopic", messages[2].Topic)
require.Equal(t, "my other message", messages[2].Message)
require.Equal(t, "This is a title", messages[2].Title)
require.Equal(t, 1, messages[2].Priority)
require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags)
}
func TestServer_StaticSites(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
rr := request(t, s, "GET", "/", "", nil)
assert.Equal(t, 200, rr.Code)
assert.Contains(t, rr.Body.String(), "</html>")
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), "</html>")
rr = request(t, s, "HEAD", "/", "", nil)
assert.Equal(t, 200, rr.Code)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "OPTIONS", "/", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "GET", "/does-not-exist.txt", "", nil)
assert.Equal(t, 404, rr.Code)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/mytopic", "", nil)
assert.Equal(t, 200, rr.Code)
assert.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
rr = request(t, s, "GET", "/static/css/app.css", "", nil)
assert.Equal(t, 200, rr.Code)
assert.Contains(t, rr.Body.String(), `html, body {`)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `html, body {`)
rr = request(t, s, "GET", "/docs", "", nil)
assert.Equal(t, 301, rr.Code)
require.Equal(t, 301, rr.Code)
rr = request(t, s, "GET", "/docs/", "", nil)
assert.Equal(t, 200, rr.Code)
assert.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`)
assert.Contains(t, rr.Body.String(), `<script src=static/js/extra.js></script>`)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`)
require.Contains(t, rr.Body.String(), `<script src=static/js/extra.js></script>`)
rr = request(t, s, "GET", "/example.html", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), "</html>")
}
func TestServer_PublishLargeMessage(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := strings.Repeat("this is a large message", 5000)
truncated := body[0:4096]
response := request(t, s, "PUT", "/mytopic", body, nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, truncated, msg.Message)
require.Equal(t, 4096, len(msg.Message))
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, truncated, messages[0].Message)
}
func TestServer_PublishPriority(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for prio := 1; prio <= 5; prio++ {
response := request(t, s, "GET", fmt.Sprintf("/mytopic/publish?priority=%d", prio), fmt.Sprintf("priority %d", prio), nil)
msg := toMessage(t, response.Body.String())
require.Equal(t, prio, msg.Priority)
}
response := request(t, s, "GET", "/mytopic/publish?priority=min", "test", nil)
require.Equal(t, 1, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil)
require.Equal(t, 2, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil)
require.Equal(t, 3, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil)
require.Equal(t, 4, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil)
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil)
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
}
func TestServer_PublishNoCache(t *testing.T) {
@@ -159,12 +213,80 @@ func TestServer_PublishNoCache(t *testing.T) {
"Cache": "no",
})
msg := toMessage(t, response.Body.String())
assert.NotEmpty(t, msg.ID)
assert.Equal(t, "this message is not cached", msg.Message)
require.NotEmpty(t, msg.ID)
require.Equal(t, "this message is not cached", msg.Message)
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
assert.Empty(t, messages)
require.Empty(t, messages)
}
func TestServer_PublishAt(t *testing.T) {
c := newTestConfig(t)
c.MinDelay = time.Second
c.AtSenderInterval = 100 * time.Millisecond
s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"In": "1s",
})
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 0, len(messages))
time.Sleep(time.Second)
require.Nil(t, s.sendDelayedMessages())
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages = toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "a message", messages[0].Message)
}
func TestServer_PublishAtWithCacheError(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Cache": "no",
"In": "30 min",
})
require.Equal(t, 400, response.Code)
require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))
}
func TestServer_PublishAtTooShortDelay(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"In": "1s",
})
require.Equal(t, 400, response.Code)
}
func TestServer_PublishAtTooLongDelay(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"In": "99999999h",
})
require.Equal(t, 400, response.Code)
}
func TestServer_PublishAtAndPrune(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"In": "1h",
})
require.Equal(t, 200, response.Code)
s.updateStatsAndPrune() // Fire pruning
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages)) // Not affected by pruning
require.Equal(t, "a message", messages[0].Message)
}
func TestServer_PublishAndMultiPoll(t *testing.T) {
@@ -172,29 +294,29 @@ func TestServer_PublishAndMultiPoll(t *testing.T) {
response := request(t, s, "PUT", "/mytopic1", "message 1", nil)
msg := toMessage(t, response.Body.String())
assert.NotEmpty(t, msg.ID)
assert.Equal(t, "mytopic1", msg.Topic)
assert.Equal(t, "message 1", msg.Message)
require.NotEmpty(t, msg.ID)
require.Equal(t, "mytopic1", msg.Topic)
require.Equal(t, "message 1", msg.Message)
response = request(t, s, "PUT", "/mytopic2", "message 2", nil)
msg = toMessage(t, response.Body.String())
assert.NotEmpty(t, msg.ID)
assert.Equal(t, "mytopic2", msg.Topic)
assert.Equal(t, "message 2", msg.Message)
require.NotEmpty(t, msg.ID)
require.Equal(t, "mytopic2", msg.Topic)
require.Equal(t, "message 2", msg.Message)
response = request(t, s, "GET", "/mytopic1/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
assert.Equal(t, 1, len(messages))
assert.Equal(t, "mytopic1", messages[0].Topic)
assert.Equal(t, "message 1", messages[0].Message)
require.Equal(t, 1, len(messages))
require.Equal(t, "mytopic1", messages[0].Topic)
require.Equal(t, "message 1", messages[0].Message)
response = request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1", "", nil)
messages = toMessages(t, response.Body.String())
assert.Equal(t, 2, len(messages))
assert.Equal(t, "mytopic1", messages[0].Topic)
assert.Equal(t, "message 1", messages[0].Message)
assert.Equal(t, "mytopic2", messages[1].Topic)
assert.Equal(t, "message 2", messages[1].Message)
require.Equal(t, 2, len(messages))
require.Equal(t, "mytopic1", messages[0].Topic)
require.Equal(t, "message 1", messages[0].Message)
require.Equal(t, "mytopic2", messages[1].Topic)
require.Equal(t, "message 2", messages[1].Message)
}
func TestServer_PublishWithNopCache(t *testing.T) {
@@ -206,27 +328,276 @@ func TestServer_PublishWithNopCache(t *testing.T) {
subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR)
publishRR := request(t, s, "PUT", "/mytopic", "my first message", nil)
assert.Equal(t, 200, publishRR.Code)
require.Equal(t, 200, publishRR.Code)
subscribeCancel()
messages := toMessages(t, subscribeRR.Body.String())
assert.Equal(t, 2, len(messages))
assert.Equal(t, openEvent, messages[0].Event)
assert.Equal(t, messageEvent, messages[1].Event)
assert.Equal(t, "my first message", messages[1].Message)
require.Equal(t, 2, len(messages))
require.Equal(t, openEvent, messages[0].Event)
require.Equal(t, messageEvent, messages[1].Event)
require.Equal(t, "my first message", messages[1].Message)
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages = toMessages(t, response.Body.String())
assert.Empty(t, messages)
require.Empty(t, messages)
}
func newTestConfig(t *testing.T) *config.Config {
conf := config.New(":80")
func TestServer_PublishAndPollSince(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
request(t, s, "PUT", "/mytopic", "test 1", nil)
time.Sleep(1100 * time.Millisecond)
since := time.Now().Unix()
request(t, s, "PUT", "/mytopic", "test 2", nil)
response := request(t, s, "GET", fmt.Sprintf("/mytopic/json?poll=1&since=%d", since), "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "test 2", messages[0].Message)
}
func TestServer_PublishViaGET(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic/trigger", "", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "triggered", msg.Message)
response = request(t, s, "GET", "/mytopic/send?message=This+is+a+test&t=This+is+a+title&tags=skull&x-priority=5&delay=24h", "", nil)
msg = toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "This is a test", msg.Message)
require.Equal(t, "This is a title", msg.Title)
require.Equal(t, []string{"skull"}, msg.Tags)
require.Equal(t, 5, msg.Priority)
require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
}
func TestServer_PublishFirebase(t *testing.T) {
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
c := newTestConfig(t)
c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
s := newTestServer(t, c)
// Normal message
response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
// Keepalive message
require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
time.Sleep(500 * time.Millisecond) // Time for sends
}
func TestServer_PollWithQueryFilters(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic?priority=1&tags=tag1,tag2", "my first message", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
response = request(t, s, "PUT", "/mytopic?title=a+title", "my second message", map[string]string{
"Tags": "tag2,tag3",
})
msg = toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
queriesThatShouldReturnMessageOne := []string{
"/mytopic/json?poll=1&priority=1",
"/mytopic/json?poll=1&priority=min",
"/mytopic/json?poll=1&priority=min,low",
"/mytopic/json?poll=1&priority=1,2",
"/mytopic/json?poll=1&p=2,min",
"/mytopic/json?poll=1&tags=tag1",
"/mytopic/json?poll=1&tags=tag1,tag2",
"/mytopic/json?poll=1&message=my+first+message",
}
for _, query := range queriesThatShouldReturnMessageOne {
response = request(t, s, "GET", query, "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages), "Query failed: "+query)
require.Equal(t, "my first message", messages[0].Message, "Query failed: "+query)
}
queriesThatShouldReturnMessageTwo := []string{
"/mytopic/json?poll=1&x-priority=3", // !
"/mytopic/json?poll=1&priority=3",
"/mytopic/json?poll=1&priority=default",
"/mytopic/json?poll=1&p=3",
"/mytopic/json?poll=1&x-tags=tag2,tag3",
"/mytopic/json?poll=1&tags=tag2,tag3",
"/mytopic/json?poll=1&tag=tag2,tag3",
"/mytopic/json?poll=1&ta=tag2,tag3",
"/mytopic/json?poll=1&x-title=a+title",
"/mytopic/json?poll=1&title=a+title",
"/mytopic/json?poll=1&t=a+title",
"/mytopic/json?poll=1&x-message=my+second+message",
"/mytopic/json?poll=1&message=my+second+message",
"/mytopic/json?poll=1&m=my+second+message",
"/mytopic/json?x-poll=1&m=my+second+message",
"/mytopic/json?po=1&m=my+second+message",
}
for _, query := range queriesThatShouldReturnMessageTwo {
response = request(t, s, "GET", query, "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages), "Query failed: "+query)
require.Equal(t, "my second message", messages[0].Message, "Query failed: "+query)
}
queriesThatShouldReturnNoMessages := []string{
"/mytopic/json?poll=1&priority=4",
"/mytopic/json?poll=1&tags=tag1,tag2,tag3",
"/mytopic/json?poll=1&title=another+title",
"/mytopic/json?poll=1&message=my+third+message",
"/mytopic/json?poll=1&message=my+third+message",
}
for _, query := range queriesThatShouldReturnNoMessages {
response = request(t, s, "GET", query, "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 0, len(messages), "Query failed: "+query)
}
}
func TestServer_SubscribeWithQueryFilters(t *testing.T) {
c := newTestConfig(t)
c.KeepaliveInterval = 800 * time.Millisecond
s := newTestServer(t, c)
subscribeResponse := httptest.NewRecorder()
subscribeCancel := subscribe(t, s, "/mytopic/json?tags=zfs-issue", subscribeResponse)
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
require.Equal(t, 200, response.Code)
response = request(t, s, "PUT", "/mytopic", "ZFS scrub failed", map[string]string{
"Tags": "zfs-issue,zfs-scrub",
})
require.Equal(t, 200, response.Code)
time.Sleep(850 * time.Millisecond)
subscribeCancel()
messages := toMessages(t, subscribeResponse.Body.String())
require.Equal(t, 3, len(messages))
require.Equal(t, openEvent, messages[0].Event)
require.Equal(t, messageEvent, messages[1].Event)
require.Equal(t, "ZFS scrub failed", messages[1].Message)
require.Equal(t, keepaliveEvent, messages[2].Event)
}
/*
func TestServer_Curl_Publish_Poll(t *testing.T) {
s, port := test.StartServer(t)
defer test.StopServer(t, s, port)
cmd := exec.Command("sh", "-c", fmt.Sprintf(`curl -sd "This is a test" localhost:%d/mytopic`, port))
require.Nil(t, cmd.Run())
b, err := cmd.CombinedOutput()
require.Nil(t, err)
msg := toMessage(t, string(b))
require.Equal(t, "This is a test", msg.Message)
cmd = exec.Command("sh", "-c", fmt.Sprintf(`curl "localhost:%d/mytopic?poll=1"`, port))
require.Nil(t, cmd.Run())
b, err = cmd.CombinedOutput()
require.Nil(t, err)
msg = toMessage(t, string(b))
require.Equal(t, "This is a test", msg.Message)
}
*/
type testMailer struct {
count int
mu sync.Mutex
}
func (t *testMailer) Send(from, to string, m *message) error {
t.mu.Lock()
defer t.mu.Unlock()
t.count++
return nil
}
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
s.mailer = &testMailer{}
for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
c := newTestConfig(t)
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
s := newTestServer(t, c)
s.mailer = &testMailer{}
for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 429, response.Code)
time.Sleep(510 * time.Millisecond)
response = request(t, s, "PUT", "/mytopic", "this should be okay again too many", map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 200, response.Code)
response = request(t, s, "PUT", "/mytopic", "and bad again", map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 429, response.Code)
}
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
s.mailer = &testMailer{}
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
"E-Mail": "test@example.com",
"Delay": "20 min",
})
require.Equal(t, 400, response.Code)
}
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
"E-Mail": "test@example.com",
})
require.Equal(t, 400, response.Code)
}
func TestServer_UnifiedPushDiscovery(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic?up=1", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
return conf
}
func newTestServer(t *testing.T, config *config.Config) *Server {
func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config)
if err != nil {
t.Fatal(err)
@@ -278,6 +649,24 @@ func toMessages(t *testing.T, s string) []*message {
func toMessage(t *testing.T, s string) *message {
var m message
assert.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m))
require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m))
return &m
}
func toHTTPError(t *testing.T, s string) *errHTTP {
var e errHTTP
require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e))
return &e
}
func firebaseServiceAccountFile(t *testing.T) string {
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
filename := filepath.Join(t.TempDir(), "firebase.json")
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
return filename
}
t.SkipNow()
return ""
}

119
server/smtp_sender.go Normal file
View File

@@ -0,0 +1,119 @@
package server
import (
_ "embed" // required by go:embed
"encoding/json"
"fmt"
"heckel.io/ntfy/util"
"mime"
"net"
"net/smtp"
"strings"
"time"
)
type mailer interface {
Send(from, to string, m *message) error
}
type smtpSender struct {
config *Config
}
func (s *smtpSender) Send(senderIP, to string, m *message) error {
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
if err != nil {
return err
}
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
if err != nil {
return err
}
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
}
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
topicURL := baseURL + "/" + m.Topic
subject := m.Title
if subject == "" {
subject = m.Message
}
subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ")
message := m.Message
trailer := ""
if len(m.Tags) > 0 {
emojis, tags, err := toEmojis(m.Tags)
if err != nil {
return "", err
}
if len(emojis) > 0 {
subject = strings.Join(emojis, " ") + " " + subject
}
if len(tags) > 0 {
trailer = "Tags: " + strings.Join(tags, ", ")
}
}
if m.Priority != 0 && m.Priority != 3 {
priority, err := util.PriorityString(m.Priority)
if err != nil {
return "", err
}
if trailer != "" {
trailer += "\n"
}
trailer += fmt.Sprintf("Priority: %s", priority)
}
if trailer != "" {
message += "\n\n" + trailer
}
subject = mime.BEncoding.Encode("utf-8", subject)
body := `From: "{shortTopicURL}" <{from}>
To: {to}
Subject: {subject}
Content-Type: text/plain; charset="utf-8"
{message}
--
This message was sent by {ip} at {time} via {topicURL}`
body = strings.ReplaceAll(body, "{from}", from)
body = strings.ReplaceAll(body, "{to}", to)
body = strings.ReplaceAll(body, "{subject}", subject)
body = strings.ReplaceAll(body, "{message}", message)
body = strings.ReplaceAll(body, "{topicURL}", topicURL)
body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL))
body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123))
body = strings.ReplaceAll(body, "{ip}", senderIP)
return body, nil
}
var (
//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 emojis []emoji
if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil {
return nil, nil, err
}
tagsOut = make([]string, 0)
emojisOut = make([]string, 0)
nextTag:
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
for _, e := range emojis {
if util.InStringList(e.Aliases, t) {
emojisOut = append(emojisOut, e.Emoji)
continue nextTag
}
}
tagsOut = append(tagsOut, t)
}
return
}

141
server/smtp_sender_test.go Normal file
View File

@@ -0,0 +1,141 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestFormatMail_Basic(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
ID: "abc",
Time: 1640382204,
Event: "message",
Topic: "alerts",
Message: "A simple message",
})
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
To: phil@example.com
Subject: A simple message
Content-Type: text/plain; charset="utf-8"
A simple message
--
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
require.Equal(t, expected, actual)
}
func TestFormatMail_JustEmojis(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
ID: "abc",
Time: 1640382204,
Event: "message",
Topic: "alerts",
Message: "A simple message",
Tags: []string{"grinning"},
})
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
To: phil@example.com
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
Content-Type: text/plain; charset="utf-8"
A simple message
--
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
require.Equal(t, expected, actual)
}
func TestFormatMail_JustOtherTags(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
ID: "abc",
Time: 1640382204,
Event: "message",
Topic: "alerts",
Message: "A simple message",
Tags: []string{"not-an-emoji"},
})
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
To: phil@example.com
Subject: A simple message
Content-Type: text/plain; charset="utf-8"
A simple message
Tags: not-an-emoji
--
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
require.Equal(t, expected, actual)
}
func TestFormatMail_JustPriority(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
ID: "abc",
Time: 1640382204,
Event: "message",
Topic: "alerts",
Message: "A simple message",
Priority: 2,
})
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
To: phil@example.com
Subject: A simple message
Content-Type: text/plain; charset="utf-8"
A simple message
Priority: low
--
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
require.Equal(t, expected, actual)
}
func TestFormatMail_UTF8Subject(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
ID: "abc",
Time: 1640382204,
Event: "message",
Topic: "alerts",
Message: "A simple message",
Title: " :: A not so simple title öäüß ¡Hola, señor!",
})
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
To: phil@example.com
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
Content-Type: text/plain; charset="utf-8"
A simple message
--
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
require.Equal(t, expected, actual)
}
func TestFormatMail_WithAllTheThings(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
ID: "abc",
Time: 1640382204,
Event: "message",
Topic: "alerts",
Priority: 5,
Tags: []string{"warning", "skull", "tag123", "other"},
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
})
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
To: phil@example.com
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
Content-Type: text/plain; charset="utf-8"
A message that contains monkeys 🙉
No really, though. Monkeys!
Tags: tag123, other
Priority: max
--
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
require.Equal(t, expected, actual)
}

195
server/smtp_server.go Normal file
View File

@@ -0,0 +1,195 @@
package server
import (
"bytes"
"errors"
"github.com/emersion/go-smtp"
"io"
"mime"
"mime/multipart"
"net/mail"
"strings"
"sync"
)
var (
errInvalidDomain = errors.New("invalid domain")
errInvalidAddress = errors.New("invalid address")
errInvalidTopic = errors.New("invalid topic")
errTooManyRecipients = errors.New("too many recipients")
errUnsupportedContentType = errors.New("unsupported content type")
)
// smtpBackend implements SMTP server methods.
type smtpBackend struct {
config *Config
sub subscriber
success int64
failure int64
mu sync.Mutex
}
func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
return &smtpBackend{
config: conf,
sub: sub,
}
}
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
return &smtpSession{backend: b}, nil
}
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
return &smtpSession{backend: b}, nil
}
func (b *smtpBackend) Counts() (success int64, failure int64) {
b.mu.Lock()
defer b.mu.Unlock()
return b.success, b.failure
}
// smtpSession is returned after EHLO.
type smtpSession struct {
backend *smtpBackend
topic string
mu sync.Mutex
}
func (s *smtpSession) AuthPlain(username, password string) error {
return nil
}
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
return nil
}
func (s *smtpSession) Rcpt(to string) error {
return s.withFailCount(func() error {
conf := s.backend.config
addressList, err := mail.ParseAddressList(to)
if err != nil {
return err
} else if len(addressList) != 1 {
return errTooManyRecipients
}
to = addressList[0].Address
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
return errInvalidDomain
}
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
if conf.SMTPServerAddrPrefix != "" {
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
return errInvalidAddress
}
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
}
if !topicRegex.MatchString(to) {
return errInvalidTopic
}
s.mu.Lock()
s.topic = to
s.mu.Unlock()
return nil
})
}
func (s *smtpSession) Data(r io.Reader) error {
return s.withFailCount(func() error {
conf := s.backend.config
b, err := io.ReadAll(r) // Protected by MaxMessageBytes
if err != nil {
return err
}
msg, err := mail.ReadMessage(bytes.NewReader(b))
if err != nil {
return err
}
body, err := readMailBody(msg)
if err != nil {
return err
}
body = strings.TrimSpace(body)
if len(body) > conf.MessageLimit {
body = body[:conf.MessageLimit]
}
m := newDefaultMessage(s.topic, body)
subject := strings.TrimSpace(msg.Header.Get("Subject"))
if subject != "" {
dec := mime.WordDecoder{}
subject, err := dec.DecodeHeader(subject)
if err != nil {
return err
}
m.Title = subject
}
if m.Title != "" && m.Message == "" {
m.Message = m.Title // Flip them, this makes more sense
m.Title = ""
}
if err := s.backend.sub(m); err != nil {
return err
}
s.backend.mu.Lock()
s.backend.success++
s.backend.mu.Unlock()
return nil
})
}
func (s *smtpSession) Reset() {
s.mu.Lock()
s.topic = ""
s.mu.Unlock()
}
func (s *smtpSession) Logout() error {
return nil
}
func (s *smtpSession) withFailCount(fn func() error) error {
err := fn()
s.backend.mu.Lock()
defer s.backend.mu.Unlock()
if err != nil {
s.backend.failure++
}
return err
}
func readMailBody(msg *mail.Message) (string, error) {
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if contentType == "text/plain" {
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
}
return string(body), nil
}
if strings.HasPrefix(contentType, "multipart/") {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if partContentType != "text/plain" {
continue
}
body, err := io.ReadAll(part)
if err != nil {
return "", err
}
return string(body), nil
}
}
return "", errUnsupportedContentType
}

290
server/smtp_server_test.go Normal file
View File

@@ -0,0 +1,290 @@
package server
import (
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
"strings"
"testing"
)
func TestSmtpBackend_Multipart(t *testing.T) {
email := `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/plain; charset="UTF-8"
what's up
--000000000000f3320b05d42915c9
Content-Type: text/html; charset="UTF-8"
<div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div>
--000000000000f3320b05d42915c9--`
_, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "and one more", m.Title)
require.Equal(t, "what's up", m.Message)
return nil
})
session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
}
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
email := `MIME-Version: 1.0
Date: Tue, 28 Dec 2021 01:33:34 +0100
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
Subject: This email has a subject but no body
From: Phil <phil@example.com>
To: ntfy-emailtest@ntfy.sh
Content-Type: multipart/alternative; boundary="000000000000bcf4a405d429f8d4"
--000000000000bcf4a405d429f8d4
Content-Type: text/plain; charset="UTF-8"
--000000000000bcf4a405d429f8d4
Content-Type: text/html; charset="UTF-8"
<div dir="ltr"><br></div>
--000000000000bcf4a405d429f8d4--`
_, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "emailtest", m.Topic)
require.Equal(t, "", m.Title) // We flipped message and body
require.Equal(t, "This email has a subject but no body", m.Message)
return nil
})
session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
}
func TestSmtpBackend_Plaintext(t *testing.T) {
email := `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"
what's up
`
conf, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "and one more", m.Title)
require.Equal(t, "what's up", m.Message)
return nil
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
}
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
From: Phil <phil@example.com>
To: ntfy-mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8"
what's up
`
_, backend := newTestBackend(t, func(m *message) error {
require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
return nil
})
session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
}
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
email := `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"
you know this is a string.
it's a long string.
it's supposed to be longer than the max message length
which is 4096 bytes,
it used to be 512 bytes, but I increased that for the UnifiedPush support
the 512 bytes was a little short, some people said
but it kinda makes sense when you look at what it looks like one a phone
heck this wasn't even half of it so far.
so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
that should do it
`
conf, backend := newTestBackend(t, func(m *message) error {
expected := `you know this is a string.
it's a long string.
it's supposed to be longer than the max message length
which is 4096 bytes,
it used to be 512 bytes, but I increased that for the UnifiedPush support
the 512 bytes was a little short, some people said
but it kinda makes sense when you look at what it looks like one a phone
heck this wasn't even half of it so far.
so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
......................................................................
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBB`
require.Equal(t, 4096, len(expected)) // Sanity check
require.Equal(t, expected, m.Message)
return nil
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(nil)
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
}
func TestSmtpBackend_Unsupported(t *testing.T) {
email := `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/SOMETHINGELSE
what's up
`
conf, backend := newTestBackend(t, func(m *message) error {
return nil
})
conf.SMTPServerAddrPrefix = ""
session, _ := backend.Login(nil, "user", "pass")
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
}
func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
conf := newTestConfig(t)
conf.SMTPServerListen = ":25"
conf.SMTPServerDomain = "ntfy.sh"
conf.SMTPServerAddrPrefix = "ntfy-"
backend := newMailBackend(conf, sub)
return conf, backend
}

View File

@@ -1,47 +1,69 @@
package server
import (
"errors"
"golang.org/x/time/rate"
"heckel.io/ntfy/config"
"heckel.io/ntfy/util"
"sync"
"time"
)
const (
visitorExpungeAfter = 30 * time.Minute
// visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number
// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since
// they are replenished faster (typically).
visitorExpungeAfter = 24 * time.Hour
)
var (
errVisitorLimitReached = errors.New("limit reached")
)
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct {
config *config.Config
limiter *rate.Limiter
config *Config
ip string
requests *rate.Limiter
emails *rate.Limiter
subscriptions *util.Limiter
seen time.Time
mu sync.Mutex
}
func newVisitor(conf *config.Config) *visitor {
func newVisitor(conf *Config, ip string) *visitor {
return &visitor{
config: conf,
limiter: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
ip: ip,
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
seen: time.Now(),
}
}
func (v *visitor) IP() string {
return v.ip
}
func (v *visitor) RequestAllowed() error {
if !v.limiter.Allow() {
return errHTTPTooManyRequests
if !v.requests.Allow() {
return errVisitorLimitReached
}
return nil
}
func (v *visitor) AddSubscription() error {
func (v *visitor) EmailAllowed() error {
if !v.emails.Allow() {
return errVisitorLimitReached
}
return nil
}
func (v *visitor) SubscriptionAllowed() error {
v.mu.Lock()
defer v.mu.Unlock()
if err := v.subscriptions.Add(1); err != nil {
return errHTTPTooManyRequests
return errVisitorLimitReached
}
return nil
}

42
test/server.go Normal file
View File

@@ -0,0 +1,42 @@
package test
import (
"fmt"
"heckel.io/ntfy/server"
"math/rand"
"net/http"
"testing"
"time"
)
func init() {
rand.Seed(time.Now().UnixMilli())
}
// StartServer starts a server.Server with a random port and waits for the server to be up
func StartServer(t *testing.T) (*server.Server, int) {
return StartServerWithConfig(t, server.NewConfig())
}
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
port := 10000 + rand.Intn(20000)
conf.ListenHTTP = fmt.Sprintf(":%d", port)
s, err := server.New(conf)
if err != nil {
t.Fatal(err)
}
go func() {
if err := s.Run(); err != nil && err != http.ErrServerClosed {
panic(err) // 'go vet' complains about 't.Fatal(err)'
}
}()
WaitForPortUp(t, port)
return s, port
}
// StopServer stops the test server and waits for the port to be down
func StopServer(t *testing.T, s *server.Server, port int) {
s.Stop()
WaitForPortDown(t, port)
}

3
test/test.go Normal file
View File

@@ -0,0 +1,3 @@
// Package test provides test helpers for unit and integration tests.
// This code is not meant to be used outside of tests.
package test

44
test/util.go Normal file
View File

@@ -0,0 +1,44 @@
package test
import (
"net"
"strconv"
"testing"
"time"
)
// WaitForPortUp waits up to 7s for a port to come up and fails t if that fails
func WaitForPortUp(t *testing.T, port int) {
success := false
for i := 0; i < 500; i++ {
startTime := time.Now()
conn, _ := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 10*time.Millisecond)
if conn != nil {
success = true
conn.Close()
break
}
if time.Since(startTime) < 10*time.Millisecond {
time.Sleep(10*time.Millisecond - time.Since(startTime))
}
}
if !success {
t.Fatalf("Failed waiting for port %d to be UP", port)
}
}
// WaitForPortDown waits up to 5s for a port to come down and fails t if that fails
func WaitForPortDown(t *testing.T, port int) {
success := false
for i := 0; i < 100; i++ {
conn, _ := net.DialTimeout("tcp", net.JoinHostPort("", strconv.Itoa(port)), 50*time.Millisecond)
if conn == nil {
success = true
break
}
conn.Close()
}
if !success {
t.Fatalf("Failed waiting for port %d to be DOWN", port)
}
}

2
tools/fbsend/README.md Normal file
View File

@@ -0,0 +1,2 @@
# fbsend
fbsend is a tiny tool to send data messages to Firebase. It's only used for testing.

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