Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75f74b471 | ||
|
|
e50779664d | ||
|
|
51583f5d28 | ||
|
|
c3170e1eb6 | ||
|
|
bc16ef8480 | ||
|
|
6a7b20e4e3 | ||
|
|
034c81288c | ||
|
|
762333c28f | ||
|
|
38b28f9bf4 | ||
|
|
aa94410308 | ||
|
|
c76e55a1c8 | ||
|
|
f6b9ebb693 | ||
|
|
68a324c206 | ||
|
|
289a6fdd0f | ||
|
|
e8cb9e7fde | ||
|
|
b5183612be | ||
|
|
44a9509cd6 | ||
|
|
cefe276ce5 | ||
|
|
e7c19a2bad | ||
|
|
c45a28e6af | ||
|
|
70aefc2e48 | ||
|
|
014b561b29 | ||
|
|
f397456703 | ||
|
|
9171e94e5a | ||
|
|
5eca20469f | ||
|
|
5ea2751423 | ||
|
|
814690e66b | ||
|
|
9b2ddabca9 | ||
|
|
8f7b61291f | ||
|
|
523e037900 | ||
|
|
88586c8f86 | ||
|
|
24eb27d41c | ||
|
|
7a7e7ca359 | ||
|
|
41c1189fee | ||
|
|
2e40b895a7 | ||
|
|
76d102f964 | ||
|
|
807d2b0d9d | ||
|
|
b4f71ce01a | ||
|
|
722c579db0 | ||
|
|
2930c4ff62 | ||
|
|
38788bb2e9 | ||
|
|
75bef92417 | ||
|
|
eb5b86ffe2 | ||
|
|
09515f26df | ||
|
|
8a3ee987a8 | ||
|
|
47b491b6e2 | ||
|
|
91ad69dd00 | ||
|
|
521aad7db5 | ||
|
|
fe2988bb38 | ||
|
|
65a53c1100 | ||
|
|
a53f18ca7d | ||
|
|
595ea87465 | ||
|
|
7b37141e07 | ||
|
|
1fd327325f | ||
|
|
96ad49f675 | ||
|
|
35b2ca51d8 | ||
|
|
76a28b4e8b | ||
|
|
9752bd7c30 | ||
|
|
46c0039a16 | ||
|
|
d5497908bb | ||
|
|
dac88391c1 | ||
|
|
a46a520bca | ||
|
|
04719f8dee | ||
|
|
113053a9e3 | ||
|
|
7cfe909644 | ||
|
|
01a1d981cf | ||
|
|
e7f8fc93e4 | ||
|
|
b45ca6f2c0 | ||
|
|
be17294dc2 | ||
|
|
7eaa92cb20 | ||
|
|
3001e57bcc | ||
|
|
43a2acb756 | ||
|
|
bcc424f2aa | ||
|
|
ec7e58a6a2 | ||
|
|
9a0f1f22b8 | ||
|
|
d6762276f5 | ||
|
|
41514cd557 | ||
|
|
63a29380a9 | ||
|
|
eeb378cfdc | ||
|
|
7a23779d07 | ||
|
|
29628a66a6 | ||
|
|
020c058805 | ||
|
|
8a625ef786 | ||
|
|
3bc8ff0104 | ||
|
|
11b5ac49c0 | ||
|
|
f553cdb282 | ||
|
|
6b46eb46e2 | ||
|
|
7280ae1ebc | ||
|
|
873c57b3d8 | ||
|
|
c8c53eed07 | ||
|
|
6779d9dd1f | ||
|
|
85939618c8 | ||
|
|
fe5734d9f0 | ||
|
|
6a7e9071b6 | ||
|
|
68d881291c | ||
|
|
66c749d5f0 | ||
|
|
534fca0d3b | ||
|
|
b6120cf6d7 | ||
|
|
09bf13bd70 | ||
|
|
9315829bc4 | ||
|
|
85b4abde6c | ||
|
|
edb6b0cf06 | ||
|
|
f24855ca9a | ||
|
|
ddd5ce2c21 | ||
|
|
e3dfea1991 | ||
|
|
fa9d6444f5 | ||
|
|
2c1989beb0 | ||
|
|
f266afa1de | ||
|
|
5639cf7a0f | ||
|
|
a1f513f6a5 | ||
|
|
1e8421e8ce | ||
|
|
4346f55b29 | ||
|
|
92f48fbbea | ||
|
|
200dd25ffa | ||
|
|
534b93e142 | ||
|
|
02f8a32b46 | ||
|
|
9cb48dbb60 | ||
|
|
bd09fb4c54 | ||
|
|
63206f8581 | ||
|
|
de0c41ec3b | ||
|
|
eaefb436d6 | ||
|
|
5843de5dfc | ||
|
|
6abda93a14 |
2
.github/workflows/test.yaml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
@@ -2,4 +2,6 @@ dist/
|
||||
build/
|
||||
.idea/
|
||||
server/docs/
|
||||
tools/fbsend/fbsend
|
||||
playground/
|
||||
*.iml
|
||||
|
||||
@@ -47,14 +47,24 @@ 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: /var/cache/ntfy/attachments
|
||||
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 +74,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
|
||||
|
||||
28
Makefile
@@ -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
|
||||
|
||||
10
README.md
@@ -6,7 +6,8 @@
|
||||
[](https://github.com/binwiederhier/ntfy/actions)
|
||||
[](https://goreportcard.com/report/github.com/binwiederhier/ntfy)
|
||||
[](https://codecov.io/gh/binwiederhier/ntfy)
|
||||
[](https://discord.gg/cT7ECsZj9w)
|
||||
[](https://discord.gg/cT7ECsZj9w)
|
||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||
[](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,8 +37,9 @@ too.
|
||||
I welcome any and all contributions. Just create a PR or an issue.
|
||||
|
||||
## Contact me
|
||||
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).
|
||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
|
||||
[on my website](https://heckel.io/about).
|
||||
|
||||
## License
|
||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
||||
@@ -48,6 +50,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)
|
||||
|
||||
263
client/client.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// 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.
|
||||
// See PublishReader for details.
|
||||
func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
|
||||
return c.PublishReader(topic, strings.NewReader(message), options...)
|
||||
}
|
||||
|
||||
// PublishReader sends a message to a specific topic, optionally using options.
|
||||
//
|
||||
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
||||
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
||||
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
|
||||
//
|
||||
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
||||
// WithNoFirebase, and the generic WithHeader.
|
||||
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
||||
topicURL := c.expandTopicURL(topic)
|
||||
req, _ := http.NewRequest("POST", topicURL, body)
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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"])
|
||||
}
|
||||
12
client/ntfy-client.service
Normal 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
|
||||
162
client/options.go
Normal file
@@ -0,0 +1,162 @@
|
||||
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
|
||||
|
||||
// WithMessage sets the notification message. This is an alternative way to passing the message body.
|
||||
func WithMessage(message string) PublishOption {
|
||||
return WithHeader("X-Message", message)
|
||||
}
|
||||
|
||||
// WithTitle adds a title to a message
|
||||
func WithTitle(title string) PublishOption {
|
||||
return WithHeader("X-Title", title)
|
||||
}
|
||||
|
||||
// WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max),
|
||||
// or the corresponding names (see util.ParsePriority).
|
||||
func WithPriority(priority string) PublishOption {
|
||||
return WithHeader("X-Priority", priority)
|
||||
}
|
||||
|
||||
// WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list
|
||||
// of tags. To use a slice, use WithTags instead
|
||||
func WithTagsList(tags string) PublishOption {
|
||||
return WithHeader("X-Tags", tags)
|
||||
}
|
||||
|
||||
// WithTags adds a list of a tags to a message
|
||||
func WithTags(tags []string) PublishOption {
|
||||
return WithTagsList(strings.Join(tags, ","))
|
||||
}
|
||||
|
||||
// WithDelay instructs the server to send the message at a later date. The delay parameter can be a
|
||||
// Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery
|
||||
// for details.
|
||||
func WithDelay(delay string) PublishOption {
|
||||
return WithHeader("X-Delay", delay)
|
||||
}
|
||||
|
||||
// WithClick makes the notification action open the given URL as opposed to entering the detail view
|
||||
func WithClick(url string) PublishOption {
|
||||
return WithHeader("X-Click", url)
|
||||
}
|
||||
|
||||
// WithAttach sets a URL that will be used by the client to download an attachment
|
||||
func WithAttach(attach string) PublishOption {
|
||||
return WithHeader("X-Attach", attach)
|
||||
}
|
||||
|
||||
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
||||
func WithFilename(filename string) PublishOption {
|
||||
return WithHeader("X-Filename", filename)
|
||||
}
|
||||
|
||||
// WithEmail instructs the server to also send the message to the given e-mail address
|
||||
func WithEmail(email string) PublishOption {
|
||||
return WithHeader("X-Email", email)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
102
cmd/app.go
@@ -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
@@ -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
|
||||
}
|
||||
145
cmd/publish.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
|
||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
|
||||
&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 pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
|
||||
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
||||
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
||||
|
||||
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
||||
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||
|
||||
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")
|
||||
click := c.String("click")
|
||||
attach := c.String("attach")
|
||||
filename := c.String("filename")
|
||||
file := c.String("file")
|
||||
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 click != "" {
|
||||
options = append(options, client.WithClick(click))
|
||||
}
|
||||
if attach != "" {
|
||||
options = append(options, client.WithAttach(attach))
|
||||
}
|
||||
if filename != "" {
|
||||
options = append(options, client.WithFilename(filename))
|
||||
}
|
||||
if email != "" {
|
||||
options = append(options, client.WithEmail(email))
|
||||
}
|
||||
if noCache {
|
||||
options = append(options, client.WithNoCache())
|
||||
}
|
||||
if noFirebase {
|
||||
options = append(options, client.WithNoFirebase())
|
||||
}
|
||||
var body io.Reader
|
||||
if file == "" {
|
||||
body = strings.NewReader(message)
|
||||
} else {
|
||||
if message != "" {
|
||||
options = append(options, client.WithMessage(message))
|
||||
}
|
||||
if file == "-" {
|
||||
if filename == "" {
|
||||
options = append(options, client.WithFilename("stdin"))
|
||||
}
|
||||
body = c.App.Reader
|
||||
} else {
|
||||
if filename == "" {
|
||||
options = append(options, client.WithFilename(filepath.Base(file)))
|
||||
}
|
||||
body, err = os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
cl := client.New(conf)
|
||||
m, err := cl.PublishReader(topic, body, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !quiet {
|
||||
fmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
36
cmd/publish_test.go
Normal 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)
|
||||
}
|
||||
198
cmd/serve.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"math"
|
||||
"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.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"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.DefaultTotalTopicLimit, 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.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", 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")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
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")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||
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")
|
||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||
return errors.New("if attachment-cache-dir is set, base-url must also be set")
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||
}
|
||||
|
||||
// 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.AttachmentCacheDir = attachmentCacheDir
|
||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
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.TotalTopicLimit = totalTopicLimit
|
||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
||||
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
|
||||
}
|
||||
|
||||
func parseSize(s string, defaultValue int64) (v int64, err error) {
|
||||
if s == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
v, err = util.ParseSize(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
237
cmd/subscribe.go
Normal 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
|
||||
}
|
||||
@@ -1,75 +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
|
||||
DefaultAtSenderInterval = 10 * time.Second
|
||||
DefaultMinDelay = 10 * time.Second
|
||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||
DefaultMessageLimit = 512
|
||||
)
|
||||
|
||||
// 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
|
||||
AtSenderInterval time.Duration
|
||||
MessageLimit int
|
||||
MinDelay time.Duration
|
||||
MaxDelay 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,
|
||||
MessageLimit: DefaultMessageLimit,
|
||||
MinDelay: DefaultMinDelay,
|
||||
MaxDelay: DefaultMaxDelay,
|
||||
AtSenderInterval: DefaultAtSenderInterval,
|
||||
GlobalTopicLimit: DefaultGlobalTopicLimit,
|
||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||
BehindProxy: false,
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# ntfy config file
|
||||
|
||||
# Listen address for the HTTP web server
|
||||
# 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>
|
||||
#
|
||||
# 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.
|
||||
# This is optional and only required to save battery when using the Android app.
|
||||
#
|
||||
# firebase-key-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.
|
||||
#
|
||||
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
||||
#
|
||||
# Note: If you are running ntfy with systemd, make sure this cache file is owned by the
|
||||
# ntfy user and group by running: chown ntfy.ntfy <filename>.
|
||||
#
|
||||
# cache-file: <filename>
|
||||
|
||||
# Duration for which messages will be buffered before they are deleted.
|
||||
# This is required to support the "since=..." and "poll=1" parameter.
|
||||
#
|
||||
# You can disable the cache entirely by setting this to 0.
|
||||
#
|
||||
# cache-duration: 12h
|
||||
|
||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||
# intermediaries closing the connection for inactivity.
|
||||
#
|
||||
# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
|
||||
#
|
||||
# keepalive-interval: 30s
|
||||
|
||||
# Interval in which the manager prunes old messages, deletes topics
|
||||
# and prints the stats.
|
||||
#
|
||||
# manager-interval: 1m
|
||||
|
||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||
#
|
||||
# global-topic-limit: 5000
|
||||
|
||||
# Rate limiting: Number of subscriptions per visitor (IP address)
|
||||
#
|
||||
# visitor-subscription-limit: 30
|
||||
|
||||
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
||||
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
||||
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -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)
|
||||
}
|
||||
381
docs/config.md
@@ -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,122 @@ 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-for-messages), 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).
|
||||
|
||||
## Attachments
|
||||
If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
|
||||
this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`).
|
||||
Once these options are set and the directory is writable by the server user, you can upload attachments via PUT.
|
||||
|
||||
By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues
|
||||
and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download
|
||||
feature) to download the file. The following config options are relevant to attachments:
|
||||
|
||||
* `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs
|
||||
* `attachment-cache-dir` is the cache directory for attached files
|
||||
* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G)
|
||||
* `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M)
|
||||
* `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h)
|
||||
|
||||
Here's an example config using mostly the defaults (except for the cache directory, which is empty by default):
|
||||
|
||||
=== "/etc/ntfy/server.yml (minimal)"
|
||||
``` yaml
|
||||
base-url: "https://ntfy.sh"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
=== "/etc/ntfy/server.yml (all options)"
|
||||
``` yaml
|
||||
base-url: "https://ntfy.sh"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
attachment-total-size-limit: "5G"
|
||||
attachment-file-size-limit: "15M"
|
||||
attachment-expiry-duration: "3h"
|
||||
visitor-attachment-total-size-limit: "100M"
|
||||
visitor-attachment-daily-bandwidth-limit: "500M"
|
||||
```
|
||||
|
||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit`
|
||||
and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.
|
||||
|
||||
## 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>
|
||||
{ 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
|
||||
```
|
||||
@@ -65,7 +161,7 @@ which lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challe
|
||||
HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to
|
||||
be incredibly helpful.
|
||||
|
||||
### nginx/Apache2
|
||||
### nginx/Apache2/caddy
|
||||
For your convenience, here's a working config that'll help configure things behind a proxy. In this
|
||||
example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
||||
or the root domain:
|
||||
@@ -83,7 +179,7 @@ or the root domain:
|
||||
if ($request_method = GET) {
|
||||
set $redirect_https "yes";
|
||||
}
|
||||
if ($request_uri ~* "^/[-_a-z0-9]{0,64}$") {
|
||||
if ($request_uri ~* "^/([-_a-z0-9]{0,64}$|docs/|static/)") {
|
||||
set $redirect_https "${redirect_https}yes";
|
||||
}
|
||||
if ($redirect_https = "yesyes") {
|
||||
@@ -94,16 +190,17 @@ or the root domain:
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 1m;
|
||||
proxy_send_timeout 1m;
|
||||
proxy_read_timeout 1m;
|
||||
proxy_connect_timeout 3m;
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,18 +219,19 @@ or the root domain:
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:2586;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 1m;
|
||||
proxy_send_timeout 1m;
|
||||
proxy_read_timeout 1m;
|
||||
proxy_connect_timeout 3m;
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -149,7 +247,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 +271,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
|
||||
@@ -184,6 +282,19 @@ or the root domain:
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
=== "caddy"
|
||||
```
|
||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||
# via Discord/Matrix or in a GitHub issue.
|
||||
|
||||
ntfy.sh {
|
||||
reverse_proxy 127.0.0.1:2586
|
||||
}
|
||||
http://nfty.sh {
|
||||
reverse_proxy 127.0.0.1:2586
|
||||
}
|
||||
```
|
||||
|
||||
## Firebase (FCM)
|
||||
!!! info
|
||||
Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app).
|
||||
@@ -200,7 +311,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,17 +325,26 @@ 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:
|
||||
There are various limits and rate limits in place that you can use to configure the server:
|
||||
|
||||
* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 5000.
|
||||
* **Global limit**: A global limit applies across all visitors (IPs, clients, users)
|
||||
* **Visitor limit**: A visitor limit only applies to a certain visitor. A **visitor** is identified by its IP address
|
||||
(or the `X-Forwarded-For` header if `behind-proxy` is set). All config options that start with the word `visitor` apply
|
||||
only on a per-visitor basis.
|
||||
|
||||
During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails
|
||||
(e.g. when you reconnect after a connection drop), it shouldn't have any effect.
|
||||
|
||||
### General limits
|
||||
Let's do the easy limits first:
|
||||
|
||||
* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000.
|
||||
* `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30.
|
||||
|
||||
A **visitor** is identified by its IP address (or the `X-Forwarded-For` header if `behind-proxy` is set). All config
|
||||
options that start with the word `visitor` apply only on a per-visitor basis.
|
||||
|
||||
### Request limits
|
||||
In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests.
|
||||
This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)):
|
||||
|
||||
@@ -235,9 +355,23 @@ 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.
|
||||
### Attachment limits
|
||||
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||
per-visitor limits:
|
||||
|
||||
* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.
|
||||
The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`,
|
||||
see [publishing docs](publish.md#attachments)) do not count here.
|
||||
* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor,
|
||||
including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in
|
||||
most cloud providers. This defaults to 500M.
|
||||
|
||||
### E-mail limits
|
||||
Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications)
|
||||
are enabled):
|
||||
|
||||
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
||||
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
||||
|
||||
## 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,
|
||||
@@ -249,7 +383,7 @@ Depending on *how you run it*, here are a few limits that are relevant:
|
||||
|
||||
### For systemd services
|
||||
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
||||
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10000. You can override it
|
||||
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
|
||||
by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf`
|
||||
is not relevant.
|
||||
|
||||
@@ -262,7 +396,7 @@ is not relevant.
|
||||
|
||||
### Outside of systemd
|
||||
If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to
|
||||
increase the `nofile` setting. Here's an example that increases the limit to 5000. You can find out the current setting
|
||||
increase the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting
|
||||
by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`.
|
||||
|
||||
=== "/etc/security/limits.conf"
|
||||
@@ -285,6 +419,7 @@ to maintain the client connection and the connection to ntfy.
|
||||
worker_connections 40500;
|
||||
}
|
||||
```
|
||||
|
||||
=== "/etc/systemd/system/nginx.service.d/override.conf"
|
||||
```
|
||||
# Allow 40,000 proxy connections (2x of the desired ntfy connection count;
|
||||
@@ -293,56 +428,142 @@ to maintain the client connection and the connection to ntfy.
|
||||
LimitNOFILE=40500
|
||||
```
|
||||
|
||||
### Banning bad actors (fail2ban)
|
||||
If you put stuff on the Internet, bad actors will try to break them or break in. [fail2ban](https://www.fail2ban.org/)
|
||||
and nginx's [ngx_http_limit_req_module module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html) can be used
|
||||
to ban client IPs if they misbehave. This is on top of the [rate limiting](#rate-limiting) inside the ntfy server.
|
||||
|
||||
Here's an example for how ntfy.sh is configured, following the instructions from two tutorials ([here](https://easyengine.io/tutorials/nginx/fail2ban/)
|
||||
and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-attack/)):
|
||||
|
||||
=== "/etc/nginx/nginx.conf"
|
||||
```
|
||||
http {
|
||||
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
|
||||
}
|
||||
```
|
||||
|
||||
=== "/etc/nginx/sites-enabled/ntfy.sh"
|
||||
```
|
||||
# For each server/location block
|
||||
server {
|
||||
location / {
|
||||
limit_req zone=one burst=1000 nodelay;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "/etc/fail2ban/filter.d/nginx-req-limit.conf"
|
||||
```
|
||||
[Definition]
|
||||
failregex = limiting requests, excess:.* by zone.*client: <HOST>
|
||||
ignoreregex =
|
||||
```
|
||||
|
||||
=== "/etc/fail2ban/jail.local"
|
||||
```
|
||||
[nginx-req-limit]
|
||||
enabled = true
|
||||
filter = nginx-req-limit
|
||||
action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
|
||||
logpath = /var/log/nginx/error.log
|
||||
findtime = 600
|
||||
bantime = 7200
|
||||
maxretry = 10
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|
|
||||
| `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. |
|
||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| 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. |
|
||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
||||
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
||||
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
||||
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
|
||||
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[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* | 15,000 | 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-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||
|
||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
|
||||
## 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]
|
||||
--attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--keepalive-interval value, -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: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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>
|
||||
{ width=500 }
|
||||
{ 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>
|
||||
|
||||
|
||||
@@ -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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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.12.1/ntfy_1.12.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
|
||||
|
||||
615
docs/publish.md
@@ -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>
|
||||
{ width=500 }
|
||||
{ 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([
|
||||
@@ -357,6 +432,13 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
|
||||
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
|
||||
@@ -382,6 +464,13 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
|
||||
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([
|
||||
@@ -397,7 +486,6 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
|
||||
|
||||
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>
|
||||
@@ -411,6 +499,446 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
|
||||
</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');
|
||||
```
|
||||
|
||||
## Click action
|
||||
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
||||
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
||||
the web browser (or the app) and open the website.
|
||||
|
||||
Here's an example that will open Reddit when the notification is clicked:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-d "New messages on Reddit" \
|
||||
-H "Click: https://www.reddit.com/message/messages" \
|
||||
ntfy.sh/reddit_alerts
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--click="https://www.reddit.com/message/messages" \
|
||||
reddit_alerts "New messages on Reddit"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /reddit_alerts HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Click: https://www.reddit.com/message/messages
|
||||
|
||||
New messages on Reddit
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/reddit_alerts', {
|
||||
method: 'POST',
|
||||
body: 'New messages on Reddit',
|
||||
headers: { 'Click': 'https://www.reddit.com/message/messages' }
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit"))
|
||||
req.Header.Set("Click", "https://www.reddit.com/message/messages")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/reddit_alerts",
|
||||
data="New messages on Reddit",
|
||||
headers={ "Click": "https://www.reddit.com/message/messages" })
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Click: https://www.reddit.com/message/messages",
|
||||
'content' => 'New messages on Reddit'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
## Attachments
|
||||
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
||||
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
||||
|
||||
There are two different ways to send attachments:
|
||||
|
||||
* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`
|
||||
* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk`
|
||||
|
||||
### Attach local file
|
||||
To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater
|
||||
than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically
|
||||
detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files
|
||||
as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases
|
||||
`Filename`, `File` or `f`).
|
||||
|
||||
By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor).
|
||||
Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app
|
||||
to auto-download it. Please also check out the [other limits below](#limitations).
|
||||
|
||||
Here's an example showing how to upload an image:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-T flower.jpg \
|
||||
-H "Filename: flower.jpg" \
|
||||
ntfy.sh/flowers
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--file=flower.jpg \
|
||||
flowers
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
PUT /flowers HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Filename: flower.jpg
|
||||
Content-Type: 52312
|
||||
|
||||
<binary JPEG data>
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/flowers', {
|
||||
method: 'PUT',
|
||||
body: document.getElementById("file").files[0],
|
||||
headers: { 'Filename': 'flower.jpg' }
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
file, _ := os.Open("flower.jpg")
|
||||
req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
|
||||
req.Header.Set("Filename", "flower.jpg")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.put("https://ntfy.sh/flowers",
|
||||
data=open("flower.jpg", 'rb'),
|
||||
headers={ "Filename": "flower.jpg" })
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'PUT',
|
||||
'header' =>
|
||||
"Content-Type: application/octet-stream\r\n" . // Does not matter
|
||||
"Filename: flower.jpg",
|
||||
'content' => file_get_contents('flower.jpg') // Dangerous for large files
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
Here's what that looks like on Android:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
<figcaption>Image attachment sent from a local file</figcaption>
|
||||
</figure>
|
||||
|
||||
### Attach file from a URL
|
||||
Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted.
|
||||
This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are
|
||||
externally hosted, the expiration or size limits from above do not apply here.
|
||||
|
||||
To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
|
||||
to specify the attachment URL. It can be any type of file. Here's an example showing how to upload an image:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Attach: https://f-droid.org/F-Droid.apk" \
|
||||
ntfy.sh/mydownloads
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--attach="https://f-droid.org/F-Droid.apk" \
|
||||
mydownloads
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mydownloads HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Attach: https://f-droid.org/F-Droid.apk
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/mydownloads', {
|
||||
method: 'POST',
|
||||
headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file)
|
||||
req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.put("https://ntfy.sh/mydownloads",
|
||||
headers={ "Attach": "https://f-droid.org/F-Droid.apk" })
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'PUT',
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" . // Does not matter
|
||||
"Attach: https://f-droid.org/F-Droid.apk",
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
<figcaption>File attachment sent from an external URL</figcaption>
|
||||
</figure>
|
||||
|
||||
## 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>
|
||||
{ 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>
|
||||
{ width=500 }
|
||||
<figcaption>Publishing a message via e-mail</figcaption>
|
||||
</figure>
|
||||
|
||||
## Advanced features
|
||||
|
||||
### Message caching
|
||||
@@ -425,8 +953,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-for-messages) 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)"
|
||||
```
|
||||
@@ -434,6 +962,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
|
||||
@@ -459,6 +994,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([
|
||||
@@ -492,6 +1034,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
|
||||
@@ -517,6 +1066,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([
|
||||
@@ -529,3 +1085,50 @@ to `no`. This will instruct the server not to forward messages to Firebase.
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
### UnifiedPush
|
||||
!!! info
|
||||
This setting is not relevant to users, only to app developers and people interested in [UnifiedPush](https://unifiedpush.org).
|
||||
|
||||
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||
|
||||
When publishing messages to a topic, apps using ntfy as a UnifiedPush distributor can set the `X-UnifiedPush` header or query
|
||||
parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Firebase](#disable-firebase). As of today, this
|
||||
option is equivalent to `Firebase: no`, but was introduced to allow future flexibility.
|
||||
|
||||
## Limitations
|
||||
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings
|
||||
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
|
||||
but just in case, let's list them all:
|
||||
|
||||
| Limit | Description |
|
||||
|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
|
||||
## List of all parameters
|
||||
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
|
||||
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-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
|
||||
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
|
||||
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
||||
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
||||
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, currently equivalent to `Firebase: no` |
|
||||
|
||||
10
docs/static/css/extra.css
vendored
@@ -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;
|
||||
}
|
||||
|
||||
BIN
docs/static/img/android-notification-settings.png
vendored
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 49 KiB |
BIN
docs/static/img/android-screenshot-add-instant.jpg
vendored
|
Before Width: | Height: | Size: 297 KiB |
BIN
docs/static/img/android-screenshot-add-instant.png
vendored
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/static/img/android-screenshot-add-other.jpg
vendored
|
Before Width: | Height: | Size: 300 KiB |
BIN
docs/static/img/android-screenshot-add-other.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/static/img/android-screenshot-add.jpg
vendored
|
Before Width: | Height: | Size: 236 KiB |
BIN
docs/static/img/android-screenshot-add.png
vendored
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/static/img/android-screenshot-attachment-file.png
vendored
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/static/img/android-screenshot-attachment-image.png
vendored
Normal file
|
After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
BIN
docs/static/img/android-screenshot-detail.jpg
vendored
|
Before Width: | Height: | Size: 255 KiB |
BIN
docs/static/img/android-screenshot-detail.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/static/img/android-screenshot-macrodroid-action.png
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/static/img/android-screenshot-macrodroid-overview.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/static/img/android-screenshot-macrodroid-send-action.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/static/img/android-screenshot-macrodroid-send-macro.png
vendored
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/static/img/android-screenshot-macrodroid-trigger.png
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/static/img/android-screenshot-main.jpg
vendored
|
Before Width: | Height: | Size: 149 KiB |
BIN
docs/static/img/android-screenshot-main.png
vendored
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/static/img/android-screenshot-muted.png
vendored
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/static/img/android-screenshot-pause.jpg
vendored
|
Before Width: | Height: | Size: 212 KiB |
BIN
docs/static/img/android-screenshot-pause.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/static/img/android-screenshot-tasker-action-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/static/img/android-screenshot-tasker-action-http-post.png
vendored
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/static/img/android-screenshot-tasker-event-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/static/img/android-screenshot-tasker-profile-send.png
vendored
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/static/img/android-screenshot-tasker-profiles.png
vendored
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/static/img/android-screenshot-tasker-task-edit-post.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/static/img/android-screenshot-tasker-task-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg
vendored
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-settings.jpg
vendored
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-subscription.jpg
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/static/img/cli-subscribe-video-1.mp4
vendored
Normal file
BIN
docs/static/img/cli-subscribe-video-2.webm
vendored
Normal file
BIN
docs/static/img/cli-subscribe-video-3.webm
vendored
Normal file
BIN
docs/static/img/overview.gif
vendored
|
Before Width: | Height: | Size: 3.7 MiB |
BIN
docs/static/img/overview.mp4
vendored
BIN
docs/static/img/screenshot-email-publishing-dns.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/static/img/screenshot-email-publishing-gmail.png
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/static/img/screenshot-email.png
vendored
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -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,72 +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 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"
|
||||
```
|
||||
|
||||
### Fetching 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"
|
||||
```
|
||||
|
||||
### 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
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
{ 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.
|
||||
|
||||
12
examples/publish-python/publish.py
Executable 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()
|
||||
8
examples/subscribe-python/subscribe.py
Executable 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)
|
||||
15
go.mod
@@ -2,22 +2,21 @@ module heckel.io/ntfy
|
||||
|
||||
go 1.17
|
||||
|
||||
replace github.com/olebedev/when => github.com/binwiederhier/when v0.0.1-binwiederhier2
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||
cloud.google.com/go/storage v1.18.2 // indirect
|
||||
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-20190311101825-c3b538a97254
|
||||
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.62.0
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
google.golang.org/api v0.63.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,8 +27,10 @@ require (
|
||||
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.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/gabriel-vasile/mimetype v1.4.0 // 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
|
||||
@@ -39,12 +40,12 @@ require (
|
||||
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-20211205182925-97ca703d548d // 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-20211208223120-3a66f561d7aa // indirect
|
||||
google.golang.org/grpc v1.42.0 // 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
|
||||
)
|
||||
|
||||
26
go.sum
@@ -25,7 +25,6 @@ cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aD
|
||||
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||
cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
|
||||
cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
@@ -60,8 +59,6 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/binwiederhier/when v0.0.1-binwiederhier2 h1:BjQC7OQI4MK0vXeltn2BEuf0Tdh/M6YNh1JrepnVr2I=
|
||||
github.com/binwiederhier/when v0.0.1-binwiederhier2/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -92,6 +89,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
|
||||
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=
|
||||
@@ -105,6 +106,8 @@ github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPO
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
|
||||
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
|
||||
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
@@ -204,6 +207,8 @@ 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=
|
||||
@@ -320,6 +325,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -400,8 +406,8 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
|
||||
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/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=
|
||||
@@ -506,8 +512,8 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
|
||||
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/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
||||
google.golang.org/api v0.62.0 h1:PhGymJMXfGBzc4lBRmrx9+1w4w2wEzURHNGF/sD/xGc=
|
||||
google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
|
||||
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=
|
||||
@@ -577,8 +583,6 @@ google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ6
|
||||
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
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-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
|
||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
@@ -608,8 +612,8 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
|
||||
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.40.1/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.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=
|
||||
|
||||
7
main.go
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,32 +4,40 @@ set -e
|
||||
# Restart systemd service if it was already running. Note that "deb-systemd-invoke try-restart" will
|
||||
# only act if the service is already running. If it's not running, it's a no-op.
|
||||
#
|
||||
# TODO: This is only tested on Debian.
|
||||
#
|
||||
if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then
|
||||
# Create ntfy user/group
|
||||
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
|
||||
chown ntfy.ntfy /var/cache/ntfy
|
||||
chmod 700 /var/cache/ntfy
|
||||
if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
# Create ntfy user/group
|
||||
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
|
||||
chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments
|
||||
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments
|
||||
|
||||
# Hack to change permissions on cache file
|
||||
configfile="/etc/ntfy/config.yml"
|
||||
if [ -f "$configfile" ]; then
|
||||
cachefile="$(cat "$configfile" | perl -n -e'/^\s*cache-file: ["'"'"']?([^"'"'"']+)["'"'"']?/ && print $1')" # Oh my, see #47
|
||||
if [ -n "$cachefile" ]; then
|
||||
chown ntfy.ntfy "$cachefile" || true
|
||||
chmod 600 "$cachefile" || true
|
||||
# Hack to change permissions on cache file
|
||||
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
|
||||
chown ntfy.ntfy "$cachefile" || true
|
||||
chmod 600 "$cachefile" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart service
|
||||
systemctl --system daemon-reload >/dev/null || true
|
||||
if systemctl is-active -q ntfy.service; then
|
||||
echo "Restarting ntfy.service ..."
|
||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||
deb-systemd-invoke try-restart ntfy.service >/dev/null || true
|
||||
else
|
||||
systemctl restart ntfy.service >/dev/null || true
|
||||
# Restart services
|
||||
systemctl --system daemon-reload >/dev/null || true
|
||||
if systemctl is-active -q ntfy.service; then
|
||||
echo "Restarting ntfy.service ..."
|
||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||
deb-systemd-invoke try-restart ntfy.service >/dev/null || true
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
set -e
|
||||
|
||||
# Delete the config if package is purged
|
||||
if [ "$1" = "purge" ]; then
|
||||
if [ "$1" = "purge" ] || [ "$1" = "0" ]; 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
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "$1" = "install" ] || [ "$1" = "upgrade" ] || [ "$1" -ge 1 ]; 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
|
||||
@@ -2,11 +2,13 @@
|
||||
set -e
|
||||
|
||||
# Stop systemd service
|
||||
if [ -d /run/systemd/system ] && [ "$1" = remove ]; then
|
||||
echo "Stopping ntfy.service ..."
|
||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||
deb-systemd-invoke stop 'ntfy.service' >/dev/null || true
|
||||
else
|
||||
systemctl stop ntfy >/dev/null 2>&1 || true
|
||||
if [ -d /run/systemd/system ]; then
|
||||
if [ "$1" = "remove" ] || [ "$1" = "0" ]; then
|
||||
echo "Stopping ntfy.service ..."
|
||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||
deb-systemd-invoke stop 'ntfy.service' >/dev/null || true
|
||||
else
|
||||
systemctl stop ntfy >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -20,4 +20,6 @@ type cache interface {
|
||||
Topics() (map[string]*topic, error)
|
||||
Prune(olderThan time.Time) error
|
||||
MarkPublished(m *message) error
|
||||
AttachmentsSize(owner string) (int64, error)
|
||||
AttachmentsExpired() ([]string, error)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,35 @@ func (c *memCache) Prune(olderThan time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memCache) AttachmentsSize(owner string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var size int64
|
||||
for topic := range c.messages {
|
||||
for _, m := range c.messages[topic] {
|
||||
counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix()
|
||||
if counted {
|
||||
size += m.Attachment.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (c *memCache) AttachmentsExpired() ([]string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ids := make([]string, 0)
|
||||
for topic := range c.messages {
|
||||
for _, m := range c.messages[topic] {
|
||||
if m.Attachment != nil && m.Attachment.Expires > 0 && m.Attachment.Expires < time.Now().Unix() {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *memCache) pruneTopic(topic string, olderThan time.Time) {
|
||||
messages := make([]*message, 0)
|
||||
for _, m := range c.messages[topic] {
|
||||
|
||||
@@ -25,6 +25,10 @@ func TestMemCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newMemCache())
|
||||
}
|
||||
|
||||
func TestMemCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newMemCache())
|
||||
}
|
||||
|
||||
func TestMemCache_NopCache(t *testing.T) {
|
||||
c := newNopCache()
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||
|
||||
@@ -15,34 +15,44 @@ 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,
|
||||
click TEXT NOT NULL,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
attachment_size INT NOT NULL,
|
||||
attachment_expires INT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
attachment_owner 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, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
insertMessageQuery = `
|
||||
INSERT INTO messages (id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT id, time, topic, message, title, priority, tags
|
||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time ASC
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT id, time, topic, message, title, priority, tags
|
||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time ASC
|
||||
`
|
||||
selectMessagesDueQuery = `
|
||||
SELECT id, time, topic, message, title, priority, tags
|
||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
`
|
||||
@@ -50,11 +60,13 @@ const (
|
||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsExpiredQuery = `SELECT id FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 2
|
||||
currentSchemaVersion = 3
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -68,9 +80,9 @@ const (
|
||||
// 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;
|
||||
`
|
||||
|
||||
@@ -78,6 +90,19 @@ const (
|
||||
migrate1To2AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
||||
`
|
||||
|
||||
// 2 -> 3
|
||||
migrate2To3AlterMessagesTableQuery = `
|
||||
BEGIN;
|
||||
ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');
|
||||
ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');
|
||||
ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
|
||||
COMMIT;
|
||||
`
|
||||
)
|
||||
|
||||
type sqliteCache struct {
|
||||
@@ -104,7 +129,35 @@ func (c *sqliteCache) AddMessage(m *message) error {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
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)
|
||||
tags := strings.Join(m.Tags, ",")
|
||||
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
||||
var attachmentSize, attachmentExpires int64
|
||||
if m.Attachment != nil {
|
||||
attachmentName = m.Attachment.Name
|
||||
attachmentType = m.Attachment.Type
|
||||
attachmentSize = m.Attachment.Size
|
||||
attachmentExpires = m.Attachment.Expires
|
||||
attachmentURL = m.Attachment.URL
|
||||
attachmentOwner = m.Attachment.Owner
|
||||
}
|
||||
_, err := c.db.Exec(
|
||||
insertMessageQuery,
|
||||
m.ID,
|
||||
m.Time,
|
||||
m.Topic,
|
||||
m.Message,
|
||||
m.Title,
|
||||
m.Priority,
|
||||
tags,
|
||||
m.Click,
|
||||
attachmentName,
|
||||
attachmentType,
|
||||
attachmentSize,
|
||||
attachmentExpires,
|
||||
attachmentURL,
|
||||
attachmentOwner,
|
||||
published,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -181,29 +234,80 @@ func (c *sqliteCache) Prune(olderThan time.Time) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var size int64
|
||||
if !rows.Next() {
|
||||
return 0, errors.New("no rows found")
|
||||
}
|
||||
if err := rows.Scan(&size); err != nil {
|
||||
return 0, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) AttachmentsExpired() ([]string, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
defer rows.Close()
|
||||
messages := make([]*message, 0)
|
||||
for rows.Next() {
|
||||
var timestamp int64
|
||||
var timestamp, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, topic, msg, title, tagsStr string
|
||||
if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr); err != nil {
|
||||
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
||||
if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
}
|
||||
var att *attachment
|
||||
if attachmentName != "" && attachmentURL != "" {
|
||||
att = &attachment{
|
||||
Name: attachmentName,
|
||||
Type: attachmentType,
|
||||
Size: attachmentSize,
|
||||
Expires: attachmentExpires,
|
||||
URL: attachmentURL,
|
||||
Owner: attachmentOwner,
|
||||
}
|
||||
}
|
||||
messages = append(messages, &message{
|
||||
ID: id,
|
||||
Time: timestamp,
|
||||
Event: messageEvent,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
ID: id,
|
||||
Time: timestamp,
|
||||
Event: messageEvent,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: click,
|
||||
Attachment: att,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -241,6 +345,8 @@ func setupDB(db *sql.DB) error {
|
||||
return migrateFrom0(db)
|
||||
} else if schemaVersion == 1 {
|
||||
return migrateFrom1(db)
|
||||
} else if schemaVersion == 2 {
|
||||
return migrateFrom2(db)
|
||||
}
|
||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||
}
|
||||
@@ -280,5 +386,16 @@ func migrateFrom1(db *sql.DB) error {
|
||||
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateFrom2(db)
|
||||
}
|
||||
|
||||
func migrateFrom2(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 2 to 3")
|
||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // Update this when a new version is added
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ func TestSqliteCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -13,71 +13,71 @@ func testCacheMessages(t *testing.T, c cache) {
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
assert.Nil(t, c.AddMessage(m1))
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||
assert.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
// Adding invalid
|
||||
assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
||||
assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||
|
||||
// mytopic: count
|
||||
count, err := c.MessageCount("mytopic")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, count)
|
||||
|
||||
// mytopic: since all
|
||||
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)
|
||||
assert.Equal(t, messageEvent, messages[0].Event)
|
||||
assert.Equal(t, "", messages[0].Title)
|
||||
assert.Equal(t, 0, messages[0].Priority)
|
||||
assert.Nil(t, messages[0].Tags)
|
||||
assert.Equal(t, "my other message", messages[1].Message)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "my message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, messageEvent, messages[0].Event)
|
||||
require.Equal(t, "", messages[0].Title)
|
||||
require.Equal(t, 0, messages[0].Priority)
|
||||
require.Nil(t, messages[0].Tags)
|
||||
require.Equal(t, "my other message", messages[1].Message)
|
||||
|
||||
// mytopic: since none
|
||||
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
||||
assert.Empty(t, messages)
|
||||
require.Empty(t, messages)
|
||||
|
||||
// mytopic: since 2
|
||||
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)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
count, err = c.MessageCount("example")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
// example: since all
|
||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||
assert.Equal(t, "my example message", messages[0].Message)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
count, err = c.MessageCount("doesnotexist")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||
assert.Empty(t, messages)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func testCacheTopics(t *testing.T, c cache) {
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
||||
|
||||
topics, err := c.Topics()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, 2, len(topics))
|
||||
assert.Equal(t, "topic1", topics["topic1"].ID)
|
||||
assert.Equal(t, "topic2", topics["topic2"].ID)
|
||||
require.Equal(t, 2, len(topics))
|
||||
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||
}
|
||||
|
||||
func testCachePrune(t *testing.T, c cache) {
|
||||
@@ -90,23 +90,23 @@ func testCachePrune(t *testing.T, c cache) {
|
||||
m3 := newDefaultMessage("another_topic", "and another one")
|
||||
m3.Time = 1
|
||||
|
||||
assert.Nil(t, c.AddMessage(m1))
|
||||
assert.Nil(t, c.AddMessage(m2))
|
||||
assert.Nil(t, c.AddMessage(m3))
|
||||
assert.Nil(t, c.Prune(time.Unix(2, 0)))
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
||||
|
||||
count, err := c.MessageCount("mytopic")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
count, err = c.MessageCount("another_topic")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
|
||||
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)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
}
|
||||
|
||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
|
||||
@@ -114,12 +114,12 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
m.Priority = 5
|
||||
m.Title = "some title"
|
||||
assert.Nil(t, c.AddMessage(m))
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
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)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||
require.Equal(t, 5, messages[0].Priority)
|
||||
require.Equal(t, "some title", messages[0].Title)
|
||||
}
|
||||
|
||||
func testCacheMessagesScheduled(t *testing.T, c cache) {
|
||||
@@ -130,20 +130,93 @@ func testCacheMessagesScheduled(t *testing.T, c cache) {
|
||||
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))
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.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)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.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)
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message) // Order!
|
||||
require.Equal(t, "message 2", messages[2].Message)
|
||||
|
||||
messages, _ = c.MessagesDue()
|
||||
assert.Empty(t, messages)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func testCacheAttachments(t *testing.T, c cache) {
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.Attachment = &attachment{
|
||||
Name: "flower.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 5000,
|
||||
Expires: expires1,
|
||||
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||
Owner: "1.2.3.4",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("mytopic", "sending you a car")
|
||||
m.ID = "m2"
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: expires2,
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
Owner: "1.2.3.4",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("another-topic", "sending you another car")
|
||||
m.ID = "m3"
|
||||
m.Attachment = &attachment{
|
||||
Name: "another-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: expires3,
|
||||
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||
Owner: "1.2.3.4",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
require.Equal(t, "flower for you", messages[0].Message)
|
||||
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
||||
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
|
||||
|
||||
require.Equal(t, "sending you a car", messages[1].Message)
|
||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
||||
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
||||
|
||||
size, err := c.AttachmentsSize("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(30000), size)
|
||||
|
||||
size, err = c.AttachmentsSize("5.6.7.8")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size)
|
||||
|
||||
ids, err := c.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"m1"}, ids)
|
||||
}
|
||||
|
||||
119
server/config.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defines default config settings (excluding limits, see below)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultKeepaliveInterval = 55 * time.Second // Not too frequently to save battery (Android read timeout is 77s!)
|
||||
DefaultManagerInterval = time.Minute
|
||||
DefaultAtSenderInterval = 10 * time.Second
|
||||
DefaultMinDelay = 10 * time.Second
|
||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
|
||||
)
|
||||
|
||||
// Defines all global and per-visitor limits
|
||||
// - message size limit: the max number of bytes for a message
|
||||
// - total topic limit: max number of topics overall
|
||||
// - various attachment limits
|
||||
const (
|
||||
DefaultMessageLengthLimit = 4096 // Bytes
|
||||
DefaultTotalTopicLimit = 15000
|
||||
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||
DefaultAttachmentExpiryDuration = 3 * time.Hour
|
||||
)
|
||||
|
||||
// Defines all per-visitor limits
|
||||
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||
// - 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 attachment size limit: total per-visitor attachment size in bytes to be stored on the server
|
||||
// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server
|
||||
const (
|
||||
DefaultVisitorSubscriptionLimit = 30
|
||||
DefaultVisitorRequestLimitBurst = 60
|
||||
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
||||
DefaultVisitorEmailLimitBurst = 16
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||
)
|
||||
|
||||
// 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
|
||||
AttachmentCacheDir string
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentExpiryDuration 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
|
||||
TotalTopicLimit int
|
||||
TotalAttachmentSizeLimit int64
|
||||
VisitorSubscriptionLimit int
|
||||
VisitorAttachmentTotalSizeLimit int64
|
||||
VisitorAttachmentDailyBandwidthLimit int
|
||||
VisitorRequestLimitBurst int
|
||||
VisitorRequestLimitReplenish time.Duration
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
BehindProxy bool
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
BaseURL: "",
|
||||
ListenHTTP: DefaultListenHTTP,
|
||||
ListenHTTPS: "",
|
||||
KeyFile: "",
|
||||
CertFile: "",
|
||||
FirebaseKeyFile: "",
|
||||
CacheFile: "",
|
||||
CacheDuration: DefaultCacheDuration,
|
||||
AttachmentCacheDir: "",
|
||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
MessageLimit: DefaultMessageLengthLimit,
|
||||
MinDelay: DefaultMinDelay,
|
||||
MaxDelay: DefaultMaxDelay,
|
||||
AtSenderInterval: DefaultAtSenderInterval,
|
||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
BehindProxy: false,
|
||||
}
|
||||
}
|
||||
13
server/config_test.go
Normal 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)
|
||||
}
|
||||
121
server/file_cache.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
errInvalidFileID = errors.New("invalid file ID")
|
||||
errFileExists = errors.New("file exists")
|
||||
)
|
||||
|
||||
type fileCache struct {
|
||||
dir string
|
||||
totalSizeCurrent int64
|
||||
totalSizeLimit int64
|
||||
fileSizeLimit int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileCache, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := dirSize(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileCache{
|
||||
dir: dir,
|
||||
totalSizeCurrent: size,
|
||||
totalSizeLimit: totalSizeLimit,
|
||||
fileSizeLimit: fileSizeLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) {
|
||||
if !fileIDRegex.MatchString(id) {
|
||||
return 0, errInvalidFileID
|
||||
}
|
||||
file := filepath.Join(c.dir, id)
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
return 0, errFileExists
|
||||
}
|
||||
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()), util.NewFixedLimiter(c.fileSizeLimit))
|
||||
limitWriter := util.NewLimitWriter(f, limiters...)
|
||||
size, err := io.Copy(limitWriter, in)
|
||||
if err != nil {
|
||||
os.Remove(file)
|
||||
return 0, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(file)
|
||||
return 0, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent += size
|
||||
c.mu.Unlock()
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (c *fileCache) Remove(ids ...string) error {
|
||||
for _, id := range ids {
|
||||
if !fileIDRegex.MatchString(id) {
|
||||
return errInvalidFileID
|
||||
}
|
||||
file := filepath.Join(c.dir, id)
|
||||
_ = os.Remove(file) // Best effort delete
|
||||
}
|
||||
size, err := dirSize(c.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent = size
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fileCache) Size() int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.totalSizeCurrent
|
||||
}
|
||||
|
||||
func (c *fileCache) Remaining() int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
remaining := c.totalSizeLimit - c.totalSizeCurrent
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func dirSize(dir string) (int64, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var size int64
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
size += info.Size()
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
83
server/file_cache_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/util"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
oneKilobyteArray = make([]byte, 1024)
|
||||
)
|
||||
|
||||
func TestFileCache_Write_Success(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), size)
|
||||
require.Equal(t, "normal file", readFile(t, dir+"/abc"))
|
||||
require.Equal(t, int64(11), c.Size())
|
||||
require.Equal(t, int64(10229), c.Remaining())
|
||||
}
|
||||
|
||||
func TestFileCache_Write_Remove_Success(t *testing.T) {
|
||||
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
|
||||
for i := 0; i < 10; i++ { // 10x999 = 9990
|
||||
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999)))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(999), size)
|
||||
}
|
||||
require.Equal(t, int64(9990), c.Size())
|
||||
require.Equal(t, int64(250), c.Remaining())
|
||||
require.FileExists(t, dir+"/abc1")
|
||||
require.FileExists(t, dir+"/abc5")
|
||||
|
||||
require.Nil(t, c.Remove("abc1", "abc5"))
|
||||
require.NoFileExists(t, dir+"/abc1")
|
||||
require.NoFileExists(t, dir+"/abc5")
|
||||
require.Equal(t, int64(7992), c.Size())
|
||||
require.Equal(t, int64(2248), c.Remaining())
|
||||
}
|
||||
|
||||
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
for i := 0; i < 10; i++ {
|
||||
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1024), size)
|
||||
}
|
||||
_, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray))
|
||||
require.Equal(t, util.ErrLimitReached, err)
|
||||
require.NoFileExists(t, dir+"/abc11")
|
||||
}
|
||||
|
||||
func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1025)))
|
||||
require.Equal(t, util.ErrLimitReached, err)
|
||||
require.NoFileExists(t, dir+"/abc")
|
||||
}
|
||||
|
||||
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
||||
require.Equal(t, util.ErrLimitReached, err)
|
||||
require.NoFileExists(t, dir+"/abc")
|
||||
}
|
||||
|
||||
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
||||
dir = t.TempDir()
|
||||
cache, err := newFileCache(dir, 10*1024, 1*1024)
|
||||
require.Nil(t, err)
|
||||
return dir, cache
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, f string) string {
|
||||
b, err := os.ReadFile(f)
|
||||
require.Nil(t, err)
|
||||
return string(b)
|
||||
}
|
||||
@@ -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
@@ -18,14 +18,25 @@ const (
|
||||
|
||||
// message represents a message published to a topic
|
||||
type message struct {
|
||||
ID string `json:"id"` // Random message ID
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ID string `json:"id"` // Random message ID
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Click string `json:"click,omitempty"`
|
||||
Attachment *attachment `json:"attachment,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
|
||||
@@ -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
|
||||
711
server/server.go
@@ -3,24 +3,31 @@ 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"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TODO add "max messages in a topic" limit
|
||||
@@ -28,23 +35,37 @@ 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
|
||||
fileCache *fileCache
|
||||
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 {
|
||||
@@ -72,14 +93,18 @@ var (
|
||||
)
|
||||
|
||||
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(|/.*)$`)
|
||||
disallowedTopics = []string{"docs", "static"}
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
disallowedTopics = []string{"docs", "static", "file"}
|
||||
attachURLRegex = regexp.MustCompile(`^https?://`)
|
||||
|
||||
templateFnMap = template.FuncMap{
|
||||
"durationToHuman": util.DurationToHuman,
|
||||
@@ -100,14 +125,41 @@ 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)}
|
||||
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", ""}
|
||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
||||
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
|
||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
|
||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
|
||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""}
|
||||
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"}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
||||
)
|
||||
|
||||
const (
|
||||
firebaseControlTopic = "~control" // See Android if changed
|
||||
emptyMessageBody = "triggered" // Used if message body is empty
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -116,6 +168,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
|
||||
@@ -124,16 +180,25 @@ func New(conf *config.Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fileCache *fileCache
|
||||
if conf.AttachmentCacheDir != "" {
|
||||
fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentFileSizeLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
config: conf,
|
||||
cache: cache,
|
||||
firebase: firebaseSubscriber,
|
||||
topics: topics,
|
||||
visitors: make(map[string]*visitor),
|
||||
config: conf,
|
||||
cache: cache,
|
||||
fileCache: fileCache,
|
||||
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 != "" {
|
||||
@@ -142,7 +207,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
|
||||
@@ -152,68 +217,136 @@ 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,
|
||||
"topic": m.Topic,
|
||||
"priority": fmt.Sprintf("%d", m.Priority),
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": m.Click,
|
||||
"title": m.Title,
|
||||
"message": m.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
if m.Attachment != nil {
|
||||
data["attachment_name"] = m.Attachment.Name
|
||||
data["attachment_type"] = m.Attachment.Type
|
||||
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
|
||||
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
||||
data["attachment_url"] = m.Attachment.URL
|
||||
}
|
||||
}
|
||||
var androidConfig *messaging.AndroidConfig
|
||||
if m.Priority >= 4 {
|
||||
androidConfig = &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
}
|
||||
}
|
||||
_, err := msg.Send(context.Background(), maybeTruncateFCMMessage(&messaging.Message{
|
||||
Topic: m.Topic,
|
||||
Data: data,
|
||||
Android: androidConfig,
|
||||
}))
|
||||
return err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
||||
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
|
||||
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
|
||||
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||
s, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
if len(s) > fcmMessageLimit {
|
||||
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
|
||||
message, ok := m.Data["message"]
|
||||
if ok && len(message) > over {
|
||||
m.Data["truncated"] = "1"
|
||||
m.Data["message"] = message[:len(message)-over]
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 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.updateStatsAndPrune()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
ticker := time.NewTicker(s.config.AtSenderInterval)
|
||||
for {
|
||||
<-ticker.C
|
||||
if err := s.sendDelayedMessages(); err != nil {
|
||||
log.Printf("error sending scheduled messages: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}()
|
||||
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.ListenHTTPS, 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 - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, e.Code, 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,17 +361,21 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.handleStatic(w, r)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.handleDocs(w, r)
|
||||
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
|
||||
return s.withRateLimit(w, r, s.handleFile)
|
||||
} 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
|
||||
@@ -251,6 +388,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
|
||||
}
|
||||
@@ -270,23 +418,52 @@ 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) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPInternalError
|
||||
}
|
||||
matches := fileRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
return errHTTPInternalErrorInvalidFilePath
|
||||
}
|
||||
messageID := matches[1]
|
||||
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
|
||||
stat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
|
||||
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader := io.LimitReader(r.Body, int64(s.config.MessageLimit))
|
||||
b, err := io.ReadAll(reader)
|
||||
defer f.Close()
|
||||
_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
m := newDefaultMessage(t.ID, string(b))
|
||||
body, err := util.Peak(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, err := s.parsePublishParams(r, v, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Message == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
cache, firebase, err := s.parseHeaders(r.Header, m)
|
||||
if err != nil {
|
||||
return err
|
||||
m.Message = emptyMessageBody
|
||||
}
|
||||
delayed := m.Time > time.Now().Unix()
|
||||
if !delayed {
|
||||
@@ -301,70 +478,116 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
|
||||
}
|
||||
}()
|
||||
}
|
||||
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 (s *Server) parseHeaders(header http.Header, m *message) (cache bool, firebase bool, err error) {
|
||||
cache = readHeader(header, "x-cache", "cache") != "no"
|
||||
firebase = readHeader(header, "x-firebase", "firebase") != "no"
|
||||
m.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":
|
||||
m.Priority = 1
|
||||
case "2", "low":
|
||||
m.Priority = 2
|
||||
case "3", "default":
|
||||
m.Priority = 3
|
||||
case "4", "high":
|
||||
m.Priority = 4
|
||||
case "5", "max", "urgent":
|
||||
m.Priority = 5
|
||||
default:
|
||||
return false, false, errHTTPBadRequest
|
||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, err error) {
|
||||
cache = readParam(r, "x-cache", "cache") != "no"
|
||||
firebase = readParam(r, "x-firebase", "firebase") != "no"
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
m.Click = readParam(r, "x-click", "click")
|
||||
filename := readParam(r, "x-filename", "filename", "file", "f")
|
||||
attach := readParam(r, "x-attach", "attach", "a")
|
||||
if attach != "" || filename != "" {
|
||||
m.Attachment = &attachment{}
|
||||
}
|
||||
if filename != "" {
|
||||
m.Attachment.Name = filename
|
||||
}
|
||||
if attach != "" {
|
||||
if !attachURLRegex.MatchString(attach) {
|
||||
return false, false, "", errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
u, err := url.Parse(m.Attachment.URL)
|
||||
if err == nil {
|
||||
m.Attachment.Name = path.Base(u.Path)
|
||||
if m.Attachment.Name == "." || m.Attachment.Name == "/" {
|
||||
m.Attachment.Name = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.Attachment.Name == "" {
|
||||
m.Attachment.Name = "attachment"
|
||||
}
|
||||
}
|
||||
tagsStr := readHeader(header, "x-tags", "tag", "tags", "ta")
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if email != "" {
|
||||
if err := v.EmailAllowed(); err != nil {
|
||||
return false, false, "", errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
}
|
||||
if s.mailer == nil && email != "" {
|
||||
return false, false, "", errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
messageStr := readParam(r, "x-message", "message", "m")
|
||||
if messageStr != "" {
|
||||
m.Message = messageStr
|
||||
}
|
||||
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 != "" {
|
||||
m.Tags = make([]string, 0)
|
||||
for _, s := range strings.Split(tagsStr, ",") {
|
||||
for _, s := range util.SplitNoEmpty(tagsStr, ",") {
|
||||
m.Tags = append(m.Tags, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
delayStr := readHeader(header, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, errHTTPBadRequest
|
||||
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, errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||
return false, false, errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||
return false, false, errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
return cache, firebase, nil
|
||||
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)
|
||||
}
|
||||
@@ -372,6 +595,81 @@ func readHeader(header http.Header, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||
//
|
||||
// 1. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||
// Body must be a message, because we attached an external URL
|
||||
// 2. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||
// Body must be attachment, because we passed a filename
|
||||
// 3. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 4. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
|
||||
if m.Attachment != nil && m.Attachment.URL != "" {
|
||||
return s.handleBodyAsMessage(m, body) // Case 1
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 2
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
|
||||
return s.handleBodyAsMessage(m, body) // Case 3
|
||||
}
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) error {
|
||||
if !utf8.Valid(body.PeakedBytes) {
|
||||
return errHTTPBadRequestMessageNotUTF8
|
||||
}
|
||||
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
||||
m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required
|
||||
}
|
||||
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
|
||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
|
||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed
|
||||
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||
}
|
||||
visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
|
||||
contentLengthStr := r.Header.Get("Content-Length")
|
||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) {
|
||||
return errHTTPBadRequestAttachmentTooLarge
|
||||
}
|
||||
}
|
||||
if m.Attachment == nil {
|
||||
m.Attachment = &attachment{}
|
||||
}
|
||||
var ext string
|
||||
m.Attachment.Owner = v.ip // Important for attachment rate limiting
|
||||
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
|
||||
m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name)
|
||||
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
|
||||
if m.Attachment.Name == "" {
|
||||
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
|
||||
}
|
||||
if m.Message == "" {
|
||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
||||
}
|
||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
|
||||
if err == util.ErrLimitReached {
|
||||
return errHTTPBadRequestAttachmentTooLarge
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
@@ -408,30 +706,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")
|
||||
scheduled := r.URL.Query().Has("scheduled") || r.URL.Query().Has("sched")
|
||||
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
|
||||
}
|
||||
@@ -473,6 +778,44 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -495,21 +838,22 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceTime, scheduled boo
|
||||
//
|
||||
// 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 {
|
||||
@@ -518,8 +862,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
|
||||
}
|
||||
@@ -532,11 +880,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
|
||||
if len(s.topics) >= s.config.TotalTopicLimit {
|
||||
return nil, errHTTPTooManyRequestsLimitTotalTopics
|
||||
}
|
||||
s.topics[id] = newTopic(id)
|
||||
}
|
||||
@@ -556,6 +904,18 @@ func (s *Server) updateStatsAndPrune() {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete expired attachments
|
||||
if s.fileCache != nil {
|
||||
ids, err := s.cache.AttachmentsExpired()
|
||||
if err == nil {
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Printf("error while deleting attachments: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Printf("error retrieving expired attachments: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Prune message cache
|
||||
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
|
||||
if err := s.cache.Prune(olderThan); err != nil {
|
||||
@@ -579,9 +939,84 @@ func (s *Server) updateStatsAndPrune() {
|
||||
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 {
|
||||
@@ -597,10 +1032,10 @@ func (s *Server) sendDelayedMessages() error {
|
||||
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())
|
||||
}
|
||||
}
|
||||
if s.firebase != nil { // Firebase subscribers may not show up in topics map
|
||||
if err := s.firebase(m); err != nil {
|
||||
log.Printf("unable to publish to Firebase: %v", err.Error())
|
||||
}
|
||||
}
|
||||
if err := s.cache.MarkPublished(m); err != nil {
|
||||
@@ -613,7 +1048,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||
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)
|
||||
}
|
||||
@@ -633,15 +1068,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++
|
||||
}
|
||||
|
||||
128
server/server.yml
Normal file
@@ -0,0 +1,128 @@
|
||||
# ntfy server config file
|
||||
|
||||
# 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-https:
|
||||
|
||||
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
|
||||
#
|
||||
# key-file:
|
||||
# cert-file:
|
||||
|
||||
# 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.
|
||||
#
|
||||
# firebase-key-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.
|
||||
#
|
||||
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
||||
#
|
||||
# Note: If you are running ntfy with systemd, make sure this cache file is owned by the
|
||||
# ntfy user and group by running: chown ntfy.ntfy <filename>.
|
||||
#
|
||||
# cache-file: <filename>
|
||||
|
||||
# Duration for which messages will be buffered before they are deleted.
|
||||
# This is required to support the "since=..." and "poll=1" parameter.
|
||||
#
|
||||
# You can disable the cache entirely by setting this to 0.
|
||||
#
|
||||
# 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, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||
# are "attachment-cache-dir" and "base-url".
|
||||
#
|
||||
# - attachment-cache-dir is the cache directory for attached files
|
||||
# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)
|
||||
# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)
|
||||
# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)
|
||||
#
|
||||
# attachment-cache-dir:
|
||||
# attachment-total-size-limit: "5G"
|
||||
# attachment-file-size-limit: "15M"
|
||||
# attachment-expiry-duration: "3h"
|
||||
|
||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
|
||||
# SMTP servers with plain text auth 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.
|
||||
#
|
||||
# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
|
||||
#
|
||||
# keepalive-interval: "30s"
|
||||
|
||||
# Interval in which the manager prunes old messages, deletes topics
|
||||
# and prints the stats.
|
||||
#
|
||||
# manager-interval: "1m"
|
||||
|
||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||
#
|
||||
# global-topic-limit: 15000
|
||||
|
||||
# Rate limiting: Number of subscriptions per visitor (IP address)
|
||||
#
|
||||
# visitor-subscription-limit: 30
|
||||
|
||||
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
||||
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
||||
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
||||
#
|
||||
# visitor-request-limit-burst: 60
|
||||
# visitor-request-limit-replenish: "10s"
|
||||
|
||||
# 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
|
||||
#
|
||||
# visitor-email-limit-burst: 16
|
||||
# visitor-email-limit-replenish: "1h"
|
||||
|
||||
# Rate limiting: Attachment size and bandwidth limits per visitor:
|
||||
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
|
||||
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
|
||||
#
|
||||
# visitor-attachment-total-size-limit: "100M"
|
||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"firebase.google.com/go/messaging"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/config"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -34,7 +38,7 @@ func TestServer_PublishAndPoll(t *testing.T) {
|
||||
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")
|
||||
require.Equal(t, 3, len(lines))
|
||||
require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message)
|
||||
@@ -132,6 +136,9 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
rr = request(t, s, "HEAD", "/", "", nil)
|
||||
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)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
@@ -150,22 +157,51 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
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) {
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = "" // Disable attachments
|
||||
s := newTestServer(t, c)
|
||||
|
||||
body := strings.Repeat("this is a large message", 5000)
|
||||
response := request(t, s, "PUT", "/mytopic", body, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishPriority(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
body := strings.Repeat("this is a large message", 1000)
|
||||
truncated := body[0:512]
|
||||
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)
|
||||
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/json?poll=1", "", nil)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, truncated, messages[0].Message)
|
||||
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)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil)
|
||||
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishNoCache(t *testing.T) {
|
||||
@@ -182,6 +218,7 @@ func TestServer_PublishNoCache(t *testing.T) {
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestServer_PublishAt(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.MinDelay = time.Second
|
||||
@@ -214,6 +251,7 @@ func TestServer_PublishAtWithCacheError(t *testing.T) {
|
||||
"In": "30 min",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))
|
||||
}
|
||||
|
||||
func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
||||
@@ -227,13 +265,28 @@ func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
||||
|
||||
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_PublishAtInvalidDelay(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40004, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAtTooLarge(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40006, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAtAndPrune(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -302,13 +355,571 @@ func TestServer_PublishWithNopCache(t *testing.T) {
|
||||
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)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&since=10s", "", nil)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "test 1", messages[0].Message)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&since=100ms", "", nil)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "test 2", messages[0].Message)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&since=INVALID", "", nil)
|
||||
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
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_PublishInvalidTopic(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.mailer = &testMailer{}
|
||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
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 TestServer_MaybeTruncateFCMMessage(t *testing.T) {
|
||||
origMessage := strings.Repeat("this is a long string", 300)
|
||||
origFCMMessage := &messaging.Message{
|
||||
Topic: "mytopic",
|
||||
Data: map[string]string{
|
||||
"id": "abcdefg",
|
||||
"time": "1641324761",
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"priority": "0",
|
||||
"tags": "",
|
||||
"title": "",
|
||||
"message": origMessage,
|
||||
},
|
||||
Android: &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
},
|
||||
}
|
||||
origMessageLength := len(origFCMMessage.Data["message"])
|
||||
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
|
||||
require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
|
||||
|
||||
truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
|
||||
truncatedMessageLength := len(truncatedFCMMessage.Data["message"])
|
||||
serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage)
|
||||
require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage))
|
||||
require.Equal(t, "1", truncatedFCMMessage.Data["truncated"])
|
||||
require.NotEqual(t, origMessageLength, truncatedMessageLength)
|
||||
}
|
||||
|
||||
func TestServer_MaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
||||
origMessage := "not really a long string"
|
||||
origFCMMessage := &messaging.Message{
|
||||
Topic: "mytopic",
|
||||
Data: map[string]string{
|
||||
"id": "abcdefg",
|
||||
"time": "1641324761",
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"priority": "0",
|
||||
"tags": "",
|
||||
"title": "",
|
||||
"message": origMessage,
|
||||
},
|
||||
}
|
||||
origMessageLength := len(origFCMMessage.Data["message"])
|
||||
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
|
||||
require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
|
||||
|
||||
notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
|
||||
notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"])
|
||||
serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage)
|
||||
require.Equal(t, origMessageLength, notTruncatedMessageLength)
|
||||
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
|
||||
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachment(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
||||
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||
size, err := s.cache.AttachmentsSize("9.9.9.9") // See request()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(5000), size)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.BehindProxy = true
|
||||
s := newTestServer(t, c)
|
||||
content := "this is an ATTACHMENT"
|
||||
response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{
|
||||
"X-Forwarded-For": "1.2.3.4",
|
||||
})
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "myfile.txt", msg.Attachment.Name)
|
||||
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||
require.Equal(t, int64(21), msg.Attachment.Size)
|
||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "21", response.Header().Get("Content-Length"))
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||
size, err := s.cache.AttachmentsSize("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(21), size)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
|
||||
"Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg",
|
||||
})
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message)
|
||||
require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name)
|
||||
require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
|
||||
require.Equal(t, "", msg.Attachment.Type)
|
||||
require.Equal(t, int64(0), msg.Attachment.Size)
|
||||
require.Equal(t, int64(0), msg.Attachment.Expires)
|
||||
require.Equal(t, "", msg.Attachment.Owner)
|
||||
|
||||
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
||||
size, err := s.cache.AttachmentsSize("127.0.0.1")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{
|
||||
"X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg",
|
||||
"File": "some file.jpg",
|
||||
})
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "This is a custom message", msg.Message)
|
||||
require.Equal(t, "some file.jpg", msg.Attachment.Name)
|
||||
require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
|
||||
require.Equal(t, "", msg.Attachment.Type)
|
||||
require.Equal(t, int64(0), msg.Attachment.Size)
|
||||
require.Equal(t, int64(0), msg.Attachment.Expires)
|
||||
require.Equal(t, "", msg.Attachment.Owner)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentBadURL(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40013, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
||||
"Content-Length": "20000000",
|
||||
})
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
||||
content := util.RandomString(5001) // > 5000, see below
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentFileSizeLimit = 5000
|
||||
s := newTestServer(t, c)
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentExpiryDuration = 10 * time.Minute
|
||||
s := newTestServer(t, c)
|
||||
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{
|
||||
"Delay": "11 min", // > AttachmentExpiryDuration
|
||||
})
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40015, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorAttachmentTotalSizeLimit = 10000
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "You received a file: attachment.txt", msg.Message)
|
||||
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||
|
||||
content := util.RandomString(5001) // 5000+5001 > , see below
|
||||
response = request(t, s, "PUT", "/mytopic", content, nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentExpiryDuration = time.Millisecond // Hack
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Publish and make sure we can retrieve it
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||
require.FileExists(t, file)
|
||||
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Prune and makes sure it's gone
|
||||
time.Sleep(time.Second) // Sigh ...
|
||||
s.updateStatsAndPrune()
|
||||
require.NoFileExists(t, file)
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfig(t)
|
||||
c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Publish attachment
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
|
||||
// Get it 4 times successfully
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
for i := 1; i <= 4; i++ { // 4 successful downloads
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, content, response.Body.String())
|
||||
}
|
||||
|
||||
// And then fail with a 429
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 429, response.Code)
|
||||
require.Equal(t, 42905, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfig(t)
|
||||
c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// 5 successful uploads
|
||||
for i := 1; i <= 5; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
}
|
||||
|
||||
// And a failed one
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
}
|
||||
|
||||
func newTestConfig(t *testing.T) *Config {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||
conf.AttachmentCacheDir = t.TempDir()
|
||||
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)
|
||||
@@ -322,6 +933,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.RemoteAddr = "9.9.9.9" // Used for tests
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
@@ -363,3 +975,21 @@ func toMessage(t *testing.T, s string) *message {
|
||||
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
@@ -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
@@ -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)
|
||||
}
|
||||