Merge branch 'main' of github.com:binwiederhier/ntfy into HEAD
28
.github/workflows/test.yaml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: test
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.17.x'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y python3-pip curl
|
||||
- name: Build docs (required for tests)
|
||||
run: make docs
|
||||
- name: Build web app (required for tests)
|
||||
run: make web
|
||||
- name: Run tests, formatting, vetting and linting
|
||||
run: make check
|
||||
- name: Run coverage
|
||||
run: make coverage
|
||||
- name: Upload coverage to codecov.io
|
||||
run: make coverage-upload
|
6
.gitignore
vendored
@ -1,5 +1,9 @@
|
||||
dist/
|
||||
build/
|
||||
.idea/
|
||||
site/
|
||||
server/docs/
|
||||
server/site/
|
||||
tools/fbsend/fbsend
|
||||
playground/
|
||||
*.iml
|
||||
node_modules/
|
||||
|
@ -1,6 +1,7 @@
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- go mod tidy
|
||||
builds:
|
||||
-
|
||||
id: ntfy
|
||||
@ -12,6 +13,9 @@ builds:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [amd64]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_armv7
|
||||
binary: ntfy
|
||||
@ -24,6 +28,9 @@ builds:
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [7]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_arm64
|
||||
binary: ntfy
|
||||
@ -35,6 +42,9 @@ builds:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm64]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
nfpms:
|
||||
-
|
||||
package_name: ntfy
|
||||
@ -47,12 +57,26 @@ nfpms:
|
||||
- rpm
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: config/config.yml
|
||||
dst: /etc/ntfy/config.yml
|
||||
type: config
|
||||
- src: config/ntfy.service
|
||||
- src: server/server.yml
|
||||
dst: /etc/ntfy/server.yml
|
||||
type: "config|noreplace"
|
||||
- src: server/ntfy.service
|
||||
dst: /lib/systemd/system/ntfy.service
|
||||
- src: client/client.yml
|
||||
dst: /etc/ntfy/client.yml
|
||||
type: "config|noreplace"
|
||||
- src: client/ntfy-client.service
|
||||
dst: /lib/systemd/system/ntfy-client.service
|
||||
- dst: /var/cache/ntfy
|
||||
type: dir
|
||||
- dst: /var/cache/ntfy/attachments
|
||||
type: dir
|
||||
- dst: /var/lib/ntfy
|
||||
type: dir
|
||||
- dst: /usr/share/ntfy/logo.png
|
||||
src: web/public/static/img/ntfy.png
|
||||
scripts:
|
||||
preinstall: "scripts/preinst.sh"
|
||||
postinstall: "scripts/postinst.sh"
|
||||
preremove: "scripts/prerm.sh"
|
||||
postremove: "scripts/postrm.sh"
|
||||
@ -62,8 +86,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
|
||||
@ -89,6 +115,7 @@ dockers:
|
||||
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
goarch: arm64
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- image_templates:
|
||||
|
2
LICENSE
@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2021 Philipp C. Heckel
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -290,8 +290,8 @@ to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
ntfy
|
||||
Copyright (C) 2021 Philipp C. Heckel
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
86
Makefile
@ -1,4 +1,3 @@
|
||||
GO=$(shell which go)
|
||||
VERSION := $(shell git describe --tag)
|
||||
|
||||
.PHONY:
|
||||
@ -38,25 +37,54 @@ help:
|
||||
@echo " make install-lint - Install golint"
|
||||
|
||||
|
||||
# Documentation
|
||||
|
||||
docs-deps: .PHONY
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
docs: docs-deps
|
||||
mkdocs build
|
||||
|
||||
|
||||
# Web app
|
||||
|
||||
web-deps:
|
||||
cd web \
|
||||
&& npm install \
|
||||
&& node_modules/svgo/bin/svgo src/img/*.svg
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
&& npm run build \
|
||||
&& mv build/index.html build/app.html \
|
||||
&& rm -rf ../server/site \
|
||||
&& mv build ../server/site \
|
||||
&& rm \
|
||||
../server/site/config.js \
|
||||
../server/site/asset-manifest.json
|
||||
|
||||
web: web-deps web-build
|
||||
|
||||
|
||||
# Test/check targets
|
||||
|
||||
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)
|
||||
@ -65,33 +93,30 @@ coverage-upload:
|
||||
# Lint/formatting targets
|
||||
|
||||
fmt:
|
||||
$(GO) fmt ./...
|
||||
gofmt -s -w .
|
||||
|
||||
fmt-check:
|
||||
test -z $(shell gofmt -l .)
|
||||
|
||||
vet:
|
||||
$(GO) vet ./...
|
||||
go vet ./...
|
||||
|
||||
lint:
|
||||
which golint || $(GO) get -u golang.org/x/lint/golint
|
||||
$(GO) list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: .PHONY
|
||||
rm -rf build/staticcheck
|
||||
which staticcheck || go get honnef.co/go/tools/cmd/staticcheck
|
||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
mkdir -p build/staticcheck
|
||||
ln -s "$(GO)" build/staticcheck/go
|
||||
ln -s "go" build/staticcheck/go
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
|
||||
rm -rf build/staticcheck
|
||||
|
||||
|
||||
# Building targets
|
||||
|
||||
docs: .PHONY
|
||||
mkdocs build
|
||||
|
||||
build-deps: docs
|
||||
build-deps: docs web
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||
|
||||
@ -102,21 +127,38 @@ 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 server/site
|
||||
touch server/docs/index.html
|
||||
touch server/site/app.html
|
||||
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 server/site
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: build-deps
|
||||
release-check-tags:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if grep -q XXXXX docs/releases.md; then\
|
||||
echo "ERROR: Must update docs/releases.md, found XXXXX.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
|
||||
echo "ERROR: Must update docs/releases.mdwith latest tag first.";\
|
||||
exit 1;\
|
||||
fi
|
||||
|
||||
release: build-deps release-check-tags check
|
||||
goreleaser release --rm-dist --debug
|
||||
|
||||
release-snapshot: build-deps
|
||||
@ -132,4 +174,4 @@ install:
|
||||
install-deb:
|
||||
sudo systemctl stop ntfy || true
|
||||
sudo apt-get purge ntfy || true
|
||||
sudo dpkg -i dist/*.deb
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
223
README.md
@ -1,8 +1,14 @@
|
||||
![ntfy](server/static/img/ntfy.png)
|
||||
![ntfy](web/public/static/img/ntfy.png)
|
||||
|
||||
# ntfy.sh | simple HTTP-based pub-sub
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest)
|
||||
[![Slack channel](https://img.shields.io/badge/slack-@gophers/binwiederhier-success.svg?logo=slack)](https://gophers.slack.com/archives/C01JMTPGF2Q)
|
||||
[![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy)
|
||||
[![Tests](https://github.com/binwiederhier/ntfy/workflows/test/badge.svg)](https://github.com/binwiederhier/ntfy/actions)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/binwiederhier/ntfy)](https://goreportcard.com/report/github.com/binwiederhier/ntfy)
|
||||
[![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy)
|
||||
[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)
|
||||
[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)
|
||||
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
|
||||
|
||||
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
||||
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
|
||||
@ -12,195 +18,28 @@ I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [op
|
||||
too.
|
||||
|
||||
<p>
|
||||
<img src="server/static/img/screenshot-curl.png" height="180">
|
||||
<img src="server/static/img/screenshot-web-detail.png" height="180">
|
||||
<img src="server/static/img/screenshot-phone-main.jpg" height="180">
|
||||
<img src="server/static/img/screenshot-phone-detail.jpg" height="180">
|
||||
<img src="server/static/img/screenshot-phone-notification.jpg" height="180">
|
||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||
<img src="web/public/static/img/screenshot-web-detail.png" height="180">
|
||||
<img src="web/public/static/img/screenshot-phone-main.jpg" height="180">
|
||||
<img src="web/public/static/img/screenshot-phone-detail.jpg" height="180">
|
||||
<img src="web/public/static/img/screenshot-phone-notification.jpg" height="180">
|
||||
</p>
|
||||
|
||||
## Usage
|
||||
## **[Documentation](https://ntfy.sh/docs/)**
|
||||
|
||||
### Publishing messages
|
||||
|
||||
Publishing messages can be done via PUT or POST using. Topics are created on the fly by subscribing or publishing to them.
|
||||
Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable.
|
||||
|
||||
Here's an example showing how to publish a message using `curl`:
|
||||
|
||||
```
|
||||
curl -d "long process is done" ntfy.sh/mytopic
|
||||
```
|
||||
|
||||
Here's an example in JS with `fetch()` (see [full example](examples)):
|
||||
|
||||
```
|
||||
fetch('https://ntfy.sh/mytopic', {
|
||||
method: 'POST', // PUT works too
|
||||
body: 'Hello from the other side.'
|
||||
})
|
||||
```
|
||||
|
||||
### Subscribe to a topic
|
||||
You can create and subscribe to a topic either in this web UI, or in your own app by subscribing to an
|
||||
[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), a JSON feed, or raw feed.
|
||||
|
||||
#### Subscribe via web
|
||||
If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic
|
||||
will show up as **desktop notification**.
|
||||
|
||||
You can try this easily on **[ntfy.sh](https://ntfy.sh)**.
|
||||
|
||||
#### Subscribe via phone
|
||||
You can use the [Ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive
|
||||
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
|
||||
|
||||
#### Subscribe via your app, or via the CLI
|
||||
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JS, you can consume
|
||||
notifications like this (see [full example](examples)):
|
||||
|
||||
```javascript
|
||||
const eventSource = new EventSource('https://ntfy.sh/mytopic/sse');<br/>
|
||||
eventSource.onmessage = (e) => {<br/>
|
||||
// Do something with e.data<br/>
|
||||
};
|
||||
```
|
||||
|
||||
You can also use the same `/sse` endpoint via `curl` or any other HTTP library:
|
||||
|
||||
```
|
||||
$ curl -s ntfy.sh/mytopic/sse
|
||||
event: open
|
||||
data: {"id":"weSj9RtNkj","time":1635528898,"event":"open","topic":"mytopic"}
|
||||
|
||||
data: {"id":"p0M5y6gcCY","time":1635528909,"event":"message","topic":"mytopic","message":"Hi!"}
|
||||
|
||||
event: keepalive
|
||||
data: {"id":"VNxNIg5fpt","time":1635528928,"event":"keepalive","topic":"test"}
|
||||
```
|
||||
|
||||
To consume JSON instead, use the `/json` endpoint, which prints one message per line:
|
||||
|
||||
```
|
||||
$ curl -s ntfy.sh/mytopic/json
|
||||
{"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}
|
||||
{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}
|
||||
{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}
|
||||
```
|
||||
|
||||
Or use the `/raw` endpoint if you need something super simple (empty lines are keepalive messages):
|
||||
|
||||
```
|
||||
$ curl -s ntfy.sh/mytopic/raw
|
||||
|
||||
This is a notification
|
||||
```
|
||||
|
||||
#### Message buffering and polling
|
||||
Messages are buffered in memory for a few hours to account for network interruptions of subscribers.
|
||||
You can read back what you missed by using the `since=...` query parameter. It takes either a
|
||||
duration (e.g. `10m` or `30s`) or a Unix timestamp (e.g. `1635528757`):
|
||||
|
||||
```
|
||||
$ curl -s "ntfy.sh/mytopic/json?since=10m"
|
||||
# Same output as above, but includes messages from up to 10 minutes ago
|
||||
```
|
||||
|
||||
You can also just poll for messages if you don't like the long-standing connection using the `poll=1`
|
||||
query parameter. The connection will end after all available messages have been read. This parameter has to be
|
||||
combined with `since=`.
|
||||
|
||||
```
|
||||
$ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m"
|
||||
# Returns messages from up to 10 minutes ago and ends the connection
|
||||
```
|
||||
|
||||
## Examples
|
||||
There are a few usage examples in the [examples](examples) directory. I'm sure there are tons of other ways to use it.
|
||||
|
||||
## Installation
|
||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||
deb/rpm packages.
|
||||
|
||||
1. Install ntfy using one of the methods described below
|
||||
2. Then (optionally) edit `/etc/ntfy/config.yml`
|
||||
3. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm).
|
||||
|
||||
### Binaries and packages
|
||||
**Debian/Ubuntu** (*from a repository*)**:**
|
||||
```bash
|
||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=amd64] https://archive.heckel.io/apt debian main' > /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
```
|
||||
|
||||
**Debian/Ubuntu** (*manual install*)**:**
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_amd64.deb
|
||||
dpkg -i ntfy_1.5.0_amd64.deb
|
||||
```
|
||||
|
||||
**Fedora/RHEL/CentOS:**
|
||||
```bash
|
||||
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_amd64.rpm
|
||||
```
|
||||
|
||||
**Docker:**
|
||||
Without cache:
|
||||
```
|
||||
docker run -p 80:80 -it binwiederhier/ntfy
|
||||
```
|
||||
|
||||
With cache:
|
||||
```bash
|
||||
docker run \
|
||||
-v /var/cache/ntfy:/var/cache/ntfy \
|
||||
-p 80:80 \
|
||||
-it \
|
||||
binwiederhier/ntfy \
|
||||
--cache-file /var/cache/ntfy/cache.db
|
||||
```
|
||||
|
||||
**Go:**
|
||||
```bash
|
||||
go get -u heckel.io/ntfy
|
||||
```
|
||||
|
||||
**Manual install:**
|
||||
```bash
|
||||
# x86_64/amd64
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_x86_64.tar.gz
|
||||
|
||||
# armv7
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.tar.gz
|
||||
|
||||
# arm64/v8
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.tar.gz
|
||||
|
||||
# Extract and run
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
./ntfy
|
||||
```
|
||||
|
||||
## Building
|
||||
Building `ntfy` is simple. Here's how you do it:
|
||||
|
||||
```
|
||||
make build-simple
|
||||
# Builds to dist/ntfy_linux_amd64/ntfy
|
||||
```
|
||||
|
||||
To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that installed, you can run `make build` or
|
||||
`make build-snapshot`.
|
||||
[Getting started](https://ntfy.sh/docs/) |
|
||||
[Android/iOS](https://ntfy.sh/docs/subscribe/phone/) |
|
||||
[API](https://ntfy.sh/docs/publish/) |
|
||||
[Install / Self-hosting](https://ntfy.sh/docs/install/) |
|
||||
[Building](https://ntfy.sh/docs/develop/)
|
||||
|
||||
## Contributing
|
||||
I welcome any and all contributions. Just create a PR or an issue.
|
||||
|
||||
## Contact me
|
||||
You can directly contact me [on Slack](https://gophers.slack.com/archives/C01JMTPGF2Q), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues),
|
||||
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).
|
||||
@ -208,11 +47,21 @@ The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GP
|
||||
|
||||
Third party libraries and resources:
|
||||
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
|
||||
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
||||
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
||||
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
|
||||
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds
|
||||
* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web
|
||||
* [React](https://reactjs.org/) (MIT) is used for the web app
|
||||
* [Material UI components](https://mui.com/) (MIT) are used in the web app
|
||||
* [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app
|
||||
* [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB
|
||||
* [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)
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
||||
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
|
||||
|
122
auth/auth.go
Normal file
@ -0,0 +1,122 @@
|
||||
// Package auth deals with authentication and authorization against topics
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Auther is a generic interface to implement password-based authentication and authorization
|
||||
type Auther interface {
|
||||
// Authenticate checks username and password and returns a user if correct. The method
|
||||
// returns in constant-ish time, regardless of whether the user exists or the password is
|
||||
// correct or incorrect.
|
||||
Authenticate(username, password string) (*User, error)
|
||||
|
||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||
// permission. The user param may be nil to signal an anonymous user.
|
||||
Authorize(user *User, topic string, perm Permission) error
|
||||
}
|
||||
|
||||
// Manager is an interface representing user and access management
|
||||
type Manager interface {
|
||||
// AddUser adds a user with the given username, password and role. The password should be hashed
|
||||
// before it is stored in a persistence layer.
|
||||
AddUser(username, password string, role Role) error
|
||||
|
||||
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
||||
// if the user did not exist in the first place.
|
||||
RemoveUser(username string) error
|
||||
|
||||
// Users returns a list of users. It always also returns the Everyone user ("*").
|
||||
Users() ([]*User, error)
|
||||
|
||||
// User returns the user with the given username if it exists, or ErrNotFound otherwise.
|
||||
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||
User(username string) (*User, error)
|
||||
|
||||
// ChangePassword changes a user's password
|
||||
ChangePassword(username, password string) error
|
||||
|
||||
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
||||
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||
ChangeRole(username string, role Role) error
|
||||
|
||||
// AllowAccess adds or updates an entry in th access control list for a specific user. It controls
|
||||
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
|
||||
AllowAccess(username string, topicPattern string, read bool, write bool) error
|
||||
|
||||
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
||||
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||
ResetAccess(username string, topicPattern string) error
|
||||
|
||||
// DefaultAccess returns the default read/write access if no access control entry matches
|
||||
DefaultAccess() (read bool, write bool)
|
||||
}
|
||||
|
||||
// User is a struct that represents a user
|
||||
type User struct {
|
||||
Name string
|
||||
Hash string // password hash (bcrypt)
|
||||
Role Role
|
||||
Grants []Grant
|
||||
}
|
||||
|
||||
// Grant is a struct that represents an access control entry to a topic
|
||||
type Grant struct {
|
||||
TopicPattern string // May include wildcard (*)
|
||||
AllowRead bool
|
||||
AllowWrite bool
|
||||
}
|
||||
|
||||
// Permission represents a read or write permission to a topic
|
||||
type Permission int
|
||||
|
||||
// Permissions to a topic
|
||||
const (
|
||||
PermissionRead = Permission(1)
|
||||
PermissionWrite = Permission(2)
|
||||
)
|
||||
|
||||
// Role represents a user's role, either admin or regular user
|
||||
type Role string
|
||||
|
||||
// User roles
|
||||
const (
|
||||
RoleAdmin = Role("admin")
|
||||
RoleUser = Role("user")
|
||||
RoleAnonymous = Role("anonymous")
|
||||
)
|
||||
|
||||
// Everyone is a special username representing anonymous users
|
||||
const (
|
||||
Everyone = "*"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||
)
|
||||
|
||||
// AllowedRole returns true if the given role can be used for new users
|
||||
func AllowedRole(role Role) bool {
|
||||
return role == RoleUser || role == RoleAdmin
|
||||
}
|
||||
|
||||
// AllowedUsername returns true if the given username is valid
|
||||
func AllowedUsername(username string) bool {
|
||||
return allowedUsernameRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||
func AllowedTopicPattern(username string) bool {
|
||||
return allowedTopicPatternRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// Error constants used by the package
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
399
auth/auth_sqlite.go
Normal file
@ -0,0 +1,399 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
bcryptCost = 10
|
||||
intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost
|
||||
)
|
||||
|
||||
// Auther-related queries
|
||||
const (
|
||||
createAuthTablesQueries = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
user TEXT NOT NULL PRIMARY KEY,
|
||||
pass TEXT NOT NULL,
|
||||
role TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS access (
|
||||
user TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
read INT NOT NULL,
|
||||
write INT NOT NULL,
|
||||
PRIMARY KEY (topic, user)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
COMMIT;
|
||||
`
|
||||
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
|
||||
selectTopicPermsQuery = `
|
||||
SELECT read, write
|
||||
FROM access
|
||||
WHERE user IN ('*', ?) AND ? LIKE topic
|
||||
ORDER BY user DESC
|
||||
`
|
||||
)
|
||||
|
||||
// Manager-related queries
|
||||
const (
|
||||
insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
|
||||
selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
|
||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||
|
||||
upsertUserAccessQuery = `
|
||||
INSERT INTO access (user, topic, read, write)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write
|
||||
`
|
||||
selectUserAccessQuery = `SELECT topic, read, write FROM access WHERE user = ?`
|
||||
deleteAllAccessQuery = `DELETE FROM access`
|
||||
deleteUserAccessQuery = `DELETE FROM access WHERE user = ?`
|
||||
deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 1
|
||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
)
|
||||
|
||||
// SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list
|
||||
// in a SQLite database.
|
||||
type SQLiteAuth struct {
|
||||
db *sql.DB
|
||||
defaultRead bool
|
||||
defaultWrite bool
|
||||
}
|
||||
|
||||
var _ Auther = (*SQLiteAuth)(nil)
|
||||
var _ Manager = (*SQLiteAuth)(nil)
|
||||
|
||||
// NewSQLiteAuth creates a new SQLiteAuth instance
|
||||
func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupAuthDB(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SQLiteAuth{
|
||||
db: db,
|
||||
defaultRead: defaultRead,
|
||||
defaultWrite: defaultWrite,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Authenticate checks username and password and returns a user if correct. The method
|
||||
// returns in constant-ish time, regardless of whether the user exists or the password is
|
||||
// correct or incorrect.
|
||||
func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
|
||||
if username == Everyone {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
user, err := a.User(username)
|
||||
if err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash),
|
||||
[]byte("intentional slow-down to avoid timing attacks"))
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||
// permission. The user param may be nil to signal an anonymous user.
|
||||
func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {
|
||||
if user != nil && user.Role == RoleAdmin {
|
||||
return nil // Admin can do everything
|
||||
}
|
||||
username := Everyone
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
}
|
||||
// Select the read/write permissions for this user/topic combo. The query may return two
|
||||
// rows (one for everyone, and one for the user), but prioritizes the user. The value for
|
||||
// user.Name may be empty (= everyone).
|
||||
rows, err := a.db.Query(selectTopicPermsQuery, username, topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return a.resolvePerms(a.defaultRead, a.defaultWrite, perm)
|
||||
}
|
||||
var read, write bool
|
||||
if err := rows.Scan(&read, &write); err != nil {
|
||||
return err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.resolvePerms(read, write, perm)
|
||||
}
|
||||
|
||||
func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
|
||||
if perm == PermissionRead && read {
|
||||
return nil
|
||||
} else if perm == PermissionWrite && write {
|
||||
return nil
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
// AddUser adds a user with the given username, password and role. The password should be hashed
|
||||
// before it is stored in a persistence layer.
|
||||
func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
|
||||
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
||||
// if the user did not exist in the first place.
|
||||
func (a *SQLiteAuth) RemoveUser(username string) error {
|
||||
if !AllowedUsername(username) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Users returns a list of users. It always also returns the Everyone user ("*").
|
||||
func (a *SQLiteAuth) Users() ([]*User, error) {
|
||||
rows, err := a.db.Query(selectUsernamesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
usernames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
rows.Close()
|
||||
users := make([]*User, 0)
|
||||
for _, username := range usernames {
|
||||
user, err := a.User(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
everyone, err := a.everyoneUser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, everyone)
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// User returns the user with the given username if it exists, or ErrNotFound otherwise.
|
||||
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||
func (a *SQLiteAuth) User(username string) (*User, error) {
|
||||
if username == Everyone {
|
||||
return a.everyoneUser()
|
||||
}
|
||||
rows, err := a.db.Query(selectUserQuery, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var hash, role string
|
||||
if !rows.Next() {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := rows.Scan(&hash, &role); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants, err := a.readGrants(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &User{
|
||||
Name: username,
|
||||
Hash: hash,
|
||||
Role: Role(role),
|
||||
Grants: grants,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *SQLiteAuth) everyoneUser() (*User, error) {
|
||||
grants, err := a.readGrants(Everyone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &User{
|
||||
Name: Everyone,
|
||||
Hash: "",
|
||||
Role: RoleAnonymous,
|
||||
Grants: grants,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
|
||||
rows, err := a.db.Query(selectUserAccessQuery, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
grants := make([]Grant, 0)
|
||||
for rows.Next() {
|
||||
var topic string
|
||||
var read, write bool
|
||||
if err := rows.Scan(&topic, &read, &write); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants = append(grants, Grant{
|
||||
TopicPattern: fromSQLWildcard(topic),
|
||||
AllowRead: read,
|
||||
AllowWrite: write,
|
||||
})
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password
|
||||
func (a *SQLiteAuth) ChangePassword(username, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
||||
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||
func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
|
||||
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil {
|
||||
return err
|
||||
}
|
||||
if role == RoleAdmin {
|
||||
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllowAccess adds or updates an entry in th access control list for a specific user. It controls
|
||||
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
|
||||
func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error {
|
||||
if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
||||
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||
func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
|
||||
if !AllowedUsername(username) && username != Everyone && username != "" {
|
||||
return ErrInvalidArgument
|
||||
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if username == "" && topicPattern == "" {
|
||||
_, err := a.db.Exec(deleteAllAccessQuery, username)
|
||||
return err
|
||||
} else if topicPattern == "" {
|
||||
_, err := a.db.Exec(deleteUserAccessQuery, username)
|
||||
return err
|
||||
}
|
||||
_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern))
|
||||
return err
|
||||
}
|
||||
|
||||
// DefaultAccess returns the default read/write access if no access control entry matches
|
||||
func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) {
|
||||
return a.defaultRead, a.defaultWrite
|
||||
}
|
||||
|
||||
func toSQLWildcard(s string) string {
|
||||
return strings.ReplaceAll(s, "*", "%")
|
||||
}
|
||||
|
||||
func fromSQLWildcard(s string) string {
|
||||
return strings.ReplaceAll(s, "%", "*")
|
||||
}
|
||||
|
||||
func setupAuthDB(db *sql.DB) error {
|
||||
// If 'schemaVersion' table does not exist, this must be a new database
|
||||
rowsSV, err := db.Query(selectSchemaVersionQuery)
|
||||
if err != nil {
|
||||
return setupNewAuthDB(db)
|
||||
}
|
||||
defer rowsSV.Close()
|
||||
|
||||
// If 'schemaVersion' table exists, read version and potentially upgrade
|
||||
schemaVersion := 0
|
||||
if !rowsSV.Next() {
|
||||
return errors.New("cannot determine schema version: database file may be corrupt")
|
||||
}
|
||||
if err := rowsSV.Scan(&schemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
rowsSV.Close()
|
||||
|
||||
// Do migrations
|
||||
if schemaVersion == currentSchemaVersion {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||
}
|
||||
|
||||
func setupNewAuthDB(db *sql.DB) error {
|
||||
if _, err := db.Exec(createAuthTablesQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
243
auth/auth_sqlite_test.go
Normal file
@ -0,0 +1,243 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/auth"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
||||
|
||||
func TestSQLiteAuth_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
|
||||
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
|
||||
require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair!
|
||||
require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false))
|
||||
require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true))
|
||||
require.Nil(t, a.AllowAccess(auth.Everyone, "up*", false, true)) // Everyone can write to /up*
|
||||
|
||||
phil, err := a.Authenticate("phil", "phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil", phil.Name)
|
||||
require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$"))
|
||||
require.Equal(t, auth.RoleAdmin, phil.Role)
|
||||
require.Equal(t, []auth.Grant{}, phil.Grants)
|
||||
|
||||
ben, err := a.Authenticate("ben", "ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ben", ben.Name)
|
||||
require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$"))
|
||||
require.Equal(t, auth.RoleUser, ben.Role)
|
||||
require.Equal(t, []auth.Grant{
|
||||
{"mytopic", true, true},
|
||||
{"readme", true, false},
|
||||
{"writeme", false, true},
|
||||
{"everyonewrite", false, false},
|
||||
}, ben.Grants)
|
||||
|
||||
notben, err := a.Authenticate("ben", "this is wrong")
|
||||
require.Nil(t, notben)
|
||||
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||
|
||||
// Admin can do everything
|
||||
require.Nil(t, a.Authorize(phil, "sometopic", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(phil, "mytopic", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(phil, "readme", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(phil, "writeme", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(phil, "announcements", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(phil, "everyonewrite", auth.PermissionWrite))
|
||||
|
||||
// User cannot do everything
|
||||
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionWrite))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "writeme", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(ben, "announcements", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "announcements", auth.PermissionWrite))
|
||||
|
||||
// Everyone else can do barely anything
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionWrite))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionWrite))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionWrite))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionRead))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionWrite))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "announcements", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(nil, "announcements", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(nil, "up1234", auth.PermissionWrite)) // Wildcard permission
|
||||
require.Nil(t, a.Authorize(nil, "up5678", auth.PermissionWrite))
|
||||
}
|
||||
|
||||
func TestSQLiteAuth_AddUser_Invalid(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
require.Equal(t, auth.ErrInvalidArgument, a.AddUser(" invalid ", "pass", auth.RoleAdmin))
|
||||
require.Equal(t, auth.ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
|
||||
}
|
||||
|
||||
func TestSQLiteAuth_AddUser_Timing(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
start := time.Now().UnixMilli()
|
||||
require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin))
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
}
|
||||
|
||||
func TestSQLiteAuth_Authenticate_Timing(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin))
|
||||
|
||||
// Timing a correct attempt
|
||||
start := time.Now().UnixMilli()
|
||||
_, err := a.Authenticate("user", "pass")
|
||||
require.Nil(t, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
|
||||
// Timing an incorrect attempt
|
||||
start = time.Now().UnixMilli()
|
||||
_, err = a.Authenticate("user", "INCORRECT")
|
||||
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
|
||||
// Timing a non-existing user attempt
|
||||
start = time.Now().UnixMilli()
|
||||
_, err = a.Authenticate("DOES-NOT-EXIST", "hithere")
|
||||
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
}
|
||||
|
||||
func TestSQLiteAuth_UserManagement(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
|
||||
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
|
||||
require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair!
|
||||
require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false))
|
||||
require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true))
|
||||
|
||||
// Query user details
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil", phil.Name)
|
||||
require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$"))
|
||||
require.Equal(t, auth.RoleAdmin, phil.Role)
|
||||
require.Equal(t, []auth.Grant{}, phil.Grants)
|
||||
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ben", ben.Name)
|
||||
require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$"))
|
||||
require.Equal(t, auth.RoleUser, ben.Role)
|
||||
require.Equal(t, []auth.Grant{
|
||||
{"mytopic", true, true},
|
||||
{"readme", true, false},
|
||||
{"writeme", false, true},
|
||||
{"everyonewrite", false, false},
|
||||
}, ben.Grants)
|
||||
|
||||
everyone, err := a.User(auth.Everyone)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "*", everyone.Name)
|
||||
require.Equal(t, "", everyone.Hash)
|
||||
require.Equal(t, auth.RoleAnonymous, everyone.Role)
|
||||
require.Equal(t, []auth.Grant{
|
||||
{"announcements", true, false},
|
||||
{"everyonewrite", true, true},
|
||||
}, everyone.Grants)
|
||||
|
||||
// Ben: Before revoking
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
|
||||
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite))
|
||||
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))
|
||||
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
|
||||
|
||||
// Revoke access for "ben" to "mytopic", then check again
|
||||
require.Nil(t, a.ResetAccess("ben", "mytopic"))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionWrite)) // Revoked
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionRead)) // Revoked
|
||||
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) // Unchanged
|
||||
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) // Unchanged
|
||||
|
||||
// Revoke rest of the access
|
||||
require.Nil(t, a.ResetAccess("ben", ""))
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionRead)) // Revoked
|
||||
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "wrtiteme", auth.PermissionWrite)) // Revoked
|
||||
|
||||
// User list
|
||||
users, err := a.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, "*", users[2].Name)
|
||||
|
||||
// Remove user
|
||||
require.Nil(t, a.RemoveUser("ben"))
|
||||
_, err = a.User("ben")
|
||||
require.Equal(t, auth.ErrNotFound, err)
|
||||
|
||||
users, err = a.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "*", users[1].Name)
|
||||
}
|
||||
|
||||
func TestSQLiteAuth_ChangePassword(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
|
||||
|
||||
_, err := a.Authenticate("phil", "phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Nil(t, a.ChangePassword("phil", "newpass"))
|
||||
_, err = a.Authenticate("phil", "phil")
|
||||
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||
_, err = a.Authenticate("phil", "newpass")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestSQLiteAuth_ChangeRole(t *testing.T) {
|
||||
a := newTestAuth(t, false, false)
|
||||
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, auth.RoleUser, ben.Role)
|
||||
require.Equal(t, 2, len(ben.Grants))
|
||||
|
||||
require.Nil(t, a.ChangeRole("ben", auth.RoleAdmin))
|
||||
|
||||
ben, err = a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, auth.RoleAdmin, ben.Role)
|
||||
require.Equal(t, 0, len(ben.Grants))
|
||||
}
|
||||
|
||||
func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth {
|
||||
filename := filepath.Join(t.TempDir(), "user.db")
|
||||
a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite)
|
||||
require.Nil(t, err)
|
||||
return a
|
||||
}
|
284
client/client.go
Normal file
@ -0,0 +1,284 @@
|
||||
// Package client provides a ntfy client to publish and subscribe to topics
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event type constants
|
||||
const (
|
||||
MessageEvent = "message"
|
||||
KeepaliveEvent = "keepalive"
|
||||
OpenEvent = "open"
|
||||
PollRequestEvent = "poll_request"
|
||||
)
|
||||
|
||||
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
|
||||
Click string
|
||||
Attachment *Attachment
|
||||
|
||||
// Additional fields
|
||||
TopicURL string
|
||||
SubscriptionID string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// Attachment represents a message attachment
|
||||
type Attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
ID string
|
||||
topicURL string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new Client using a given Config
|
||||
func New(config *Config) *Client {
|
||||
return &Client{
|
||||
Messages: make(chan *Message, 50), // Allow reading a few messages
|
||||
config: config,
|
||||
subscriptions: make(map[string]*subscription),
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends a message to a specific topic, optionally using options.
|
||||
// See PublishReader for details.
|
||||
func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
|
||||
return c.PublishReader(topic, strings.NewReader(message), options...)
|
||||
}
|
||||
|
||||
// PublishReader sends a message to a specific topic, optionally using options.
|
||||
//
|
||||
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
||||
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
||||
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
|
||||
//
|
||||
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
||||
// WithNoFirebase, and the generic WithHeader.
|
||||
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
||||
topicURL := 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()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(strings.TrimSpace(string(b)))
|
||||
}
|
||||
m, err := toMessage(string(b), topicURL, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for
|
||||
// messages and does not subscribe to messages that arrive after this call.
|
||||
//
|
||||
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
||||
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
||||
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
|
||||
//
|
||||
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
|
||||
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
|
||||
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
|
||||
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()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.TrimSpace(string(b)))
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
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
|
||||
}
|
40
client/client.yml
Normal file
@ -0,0 +1,40 @@
|
||||
# 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
|
||||
# - topic: secret
|
||||
# command: 'notify-send "$m"'
|
||||
# user: phill
|
||||
# password: mypass
|
||||
#
|
||||
# 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
|
||||
}
|
||||
}
|
44
client/config.go
Normal file
@ -0,0 +1,44 @@
|
||||
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"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
} `yaml:"subscribe"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config struct for a Client
|
||||
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
|
||||
}
|
40
client/config_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
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-with-auth
|
||||
user: phil
|
||||
password: mypass
|
||||
- topic: echo-this
|
||||
command: 'echo "Message received: $message"'
|
||||
- topic: alerts
|
||||
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
|
||||
if:
|
||||
priority: high,urgent
|
||||
`), 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-with-auth", conf.Subscribe[0].Topic)
|
||||
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||
require.Equal(t, "mypass", conf.Subscribe[0].Password)
|
||||
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
||||
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
||||
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
|
||||
require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command)
|
||||
require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"])
|
||||
}
|
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
|
168
client/options.go
Normal file
@ -0,0 +1,168 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestOption is a generic request option that can be added to Client calls
|
||||
type RequestOption = func(r *http.Request) error
|
||||
|
||||
// PublishOption is an option that can be passed to the Client.Publish call
|
||||
type PublishOption = RequestOption
|
||||
|
||||
// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call
|
||||
type SubscribeOption = RequestOption
|
||||
|
||||
// WithMessage sets the notification message. This is an alternative way to passing the message body.
|
||||
func WithMessage(message string) PublishOption {
|
||||
return WithHeader("X-Message", message)
|
||||
}
|
||||
|
||||
// WithTitle adds a title to a message
|
||||
func WithTitle(title string) PublishOption {
|
||||
return WithHeader("X-Title", title)
|
||||
}
|
||||
|
||||
// WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max),
|
||||
// or the corresponding names (see util.ParsePriority).
|
||||
func WithPriority(priority string) PublishOption {
|
||||
return WithHeader("X-Priority", priority)
|
||||
}
|
||||
|
||||
// WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list
|
||||
// of tags. To use a slice, use WithTags instead
|
||||
func WithTagsList(tags string) PublishOption {
|
||||
return WithHeader("X-Tags", tags)
|
||||
}
|
||||
|
||||
// WithTags adds a list of a tags to a message
|
||||
func WithTags(tags []string) PublishOption {
|
||||
return WithTagsList(strings.Join(tags, ","))
|
||||
}
|
||||
|
||||
// WithDelay instructs the server to send the message at a later date. The delay parameter can be a
|
||||
// Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery
|
||||
// for details.
|
||||
func WithDelay(delay string) PublishOption {
|
||||
return WithHeader("X-Delay", delay)
|
||||
}
|
||||
|
||||
// WithClick makes the notification action open the given URL as opposed to entering the detail view
|
||||
func WithClick(url string) PublishOption {
|
||||
return WithHeader("X-Click", url)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// WithBasicAuth adds the Authorization header for basic auth to the request
|
||||
func WithBasicAuth(user, pass string) PublishOption {
|
||||
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
212
cmd/access.go
Normal file
@ -0,0 +1,212 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
const (
|
||||
userEveryone = "everyone"
|
||||
)
|
||||
|
||||
var flagsAccess = append(
|
||||
userCommandFlags(),
|
||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||
)
|
||||
|
||||
var cmdAccess = &cli.Command{
|
||||
Name: "access",
|
||||
Usage: "Grant/revoke access to a topic, or show access",
|
||||
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
||||
Flags: flagsAccess,
|
||||
Before: initConfigFileInputSource("config", flagsAccess),
|
||||
Action: execUserAccess,
|
||||
Category: categoryServer,
|
||||
Description: `Manage the access control list for the ntfy server.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||
to the related command 'ntfy user'.
|
||||
|
||||
The command allows you to show the access control list, as well as change it, depending on how
|
||||
it is called.
|
||||
|
||||
Usage:
|
||||
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||
ntfy access USERNAME # Shows access control entries for USERNAME
|
||||
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
|
||||
|
||||
Arguments:
|
||||
USERNAME an existing user, as created with 'ntfy user add', or "everyone"/"*"
|
||||
to define access rules for anonymous/unauthenticated clients
|
||||
TOPIC name of a topic with optional wildcards, e.g. "mytopic*"
|
||||
PERMISSION one of the following:
|
||||
- read-write (alias: rw)
|
||||
- read-only (aliases: read, ro)
|
||||
- write-only (aliases: write, wo)
|
||||
- deny (alias: none)
|
||||
|
||||
Examples:
|
||||
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||
ntfy access phil # Shows access for user phil
|
||||
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
|
||||
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
|
||||
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
|
||||
ntfy access --reset # Reset entire access control list
|
||||
ntfy access --reset phil # Reset all access for user phil
|
||||
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
|
||||
`,
|
||||
}
|
||||
|
||||
func execUserAccess(c *cli.Context) error {
|
||||
if c.NArg() > 3 {
|
||||
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||
}
|
||||
manager, err := createAuthManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username := c.Args().Get(0)
|
||||
if username == userEveryone {
|
||||
username = auth.Everyone
|
||||
}
|
||||
topic := c.Args().Get(1)
|
||||
perms := c.Args().Get(2)
|
||||
reset := c.Bool("reset")
|
||||
if reset {
|
||||
if perms != "" {
|
||||
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||
}
|
||||
return resetAccess(c, manager, username, topic)
|
||||
} else if perms == "" {
|
||||
if topic != "" {
|
||||
return errors.New("invalid syntax, please check 'ntfy access --help' for usage details")
|
||||
}
|
||||
return showAccess(c, manager, username)
|
||||
}
|
||||
return changeAccess(c, manager, username, topic, perms)
|
||||
}
|
||||
|
||||
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
|
||||
if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
|
||||
return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)")
|
||||
}
|
||||
read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
|
||||
write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
|
||||
user, err := manager.User(username)
|
||||
if err == auth.ErrNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if user.Role == auth.RoleAdmin {
|
||||
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
||||
}
|
||||
if err := manager.AllowAccess(username, topic, read, write); err != nil {
|
||||
return err
|
||||
}
|
||||
if read && write {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||
} else if read {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||
} else if write {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
||||
}
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) error {
|
||||
if username == "" {
|
||||
return resetAllAccess(c, manager)
|
||||
} else if topic == "" {
|
||||
return resetUserAccess(c, manager, username)
|
||||
}
|
||||
return resetUserTopicAccess(c, manager, username, topic)
|
||||
}
|
||||
|
||||
func resetAllAccess(c *cli.Context, manager auth.Manager) error {
|
||||
if err := manager.ResetAccess("", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetUserAccess(c *cli.Context, manager auth.Manager, username string) error {
|
||||
if err := manager.ResetAccess(username, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, topic string) error {
|
||||
if err := manager.ResetAccess(username, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func showAccess(c *cli.Context, manager auth.Manager, username string) error {
|
||||
if username == "" {
|
||||
return showAllAccess(c, manager)
|
||||
}
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func showAllAccess(c *cli.Context, manager auth.Manager) error {
|
||||
users, err := manager.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return showUsers(c, manager, users)
|
||||
}
|
||||
|
||||
func showUserAccess(c *cli.Context, manager auth.Manager, username string) error {
|
||||
users, err := manager.User(username)
|
||||
if err == auth.ErrNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return showUsers(c, manager, []*auth.User{users})
|
||||
}
|
||||
|
||||
func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error {
|
||||
for _, user := range users {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", user.Name, user.Role)
|
||||
if user.Role == auth.RoleAdmin {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||
} else if len(user.Grants) > 0 {
|
||||
for _, grant := range user.Grants {
|
||||
if grant.AllowRead && grant.AllowWrite {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.AllowRead {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.AllowWrite {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
||||
}
|
||||
if user.Name == auth.Everyone {
|
||||
defaultRead, defaultWrite := manager.DefaultAccess()
|
||||
if defaultRead && defaultWrite {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||
} else if defaultRead {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||
} else if defaultWrite {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||
} else {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
87
cmd/access_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Access_Show(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
}
|
||||
|
||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "ben"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
expected := `user phil (admin)
|
||||
- read-write access to all topics (admin role)
|
||||
user ben (user)
|
||||
- read-write access to topic announcements
|
||||
- read-only access to topic sometopic
|
||||
user * (anonymous)
|
||||
- read-only access to topic announcements
|
||||
- no access to any (other) topics (server config)
|
||||
`
|
||||
require.Equal(t, expected, stderr.String())
|
||||
|
||||
// See if access permissions match
|
||||
app, _, _, _ = newTestApp()
|
||||
require.Error(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"publish",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"publish",
|
||||
"-u", "ben:benpass",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"publish",
|
||||
"-u", "phil:philpass",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"subscribe",
|
||||
"--poll",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Error(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"subscribe",
|
||||
"--poll",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/something-else", port),
|
||||
}))
|
||||
}
|
||||
|
||||
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"access",
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + confToDefaultAccess(conf),
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
105
cmd/app.go
@ -2,112 +2,45 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryClient = "Client commands"
|
||||
categoryServer = "Server commands"
|
||||
)
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Commands: []*cli.Command{
|
||||
// Server commands
|
||||
cmdServe,
|
||||
cmdUser,
|
||||
cmdAccess,
|
||||
|
||||
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 < 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")
|
||||
// Client commands
|
||||
cmdPublish,
|
||||
cmdSubscribe,
|
||||
},
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||
|
35
cmd/app_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"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
|
||||
}
|
178
cmd/publish.go
Normal file
@ -0,0 +1,178 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/util"
|
||||
"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]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
|
||||
Action: execPublish,
|
||||
Category: categoryClient,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do 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
|
||||
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
||||
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
|
||||
NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic
|
||||
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
||||
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
||||
|
||||
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
||||
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||
|
||||
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 {
|
||||
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")
|
||||
user := c.String("user")
|
||||
noCache := c.Bool("no-cache")
|
||||
noFirebase := c.Bool("no-firebase")
|
||||
envTopic := c.Bool("env-topic")
|
||||
quiet := c.Bool("quiet")
|
||||
var topic, message string
|
||||
if envTopic {
|
||||
topic = os.Getenv("NTFY_TOPIC")
|
||||
if c.NArg() > 0 {
|
||||
message = strings.Join(c.Args().Slice(), " ")
|
||||
}
|
||||
} else {
|
||||
if c.NArg() < 1 {
|
||||
return errors.New("must specify topic, type 'ntfy publish --help' for help")
|
||||
}
|
||||
topic = c.Args().Get(0)
|
||||
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())
|
||||
}
|
||||
if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
user = parts[0]
|
||||
pass = parts[1]
|
||||
} else {
|
||||
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||
p, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass = string(p)
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
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
|
||||
}
|
72
cmd/publish_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy", "publish",
|
||||
"--title", "this is a title",
|
||||
"--priority", "high",
|
||||
"--tags", "tag1,tag2",
|
||||
// No --delay, --email
|
||||
"--click", "https://ntfy.sh",
|
||||
"--attach", "https://f-droid.org/F-Droid.apk",
|
||||
"--filename", "fdroid.apk",
|
||||
"--no-cache",
|
||||
"--no-firebase",
|
||||
topic,
|
||||
"some message",
|
||||
}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "message", m.Event)
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
require.Equal(t, "some message", m.Message)
|
||||
require.Equal(t, "this is a title", m.Title)
|
||||
require.Equal(t, 4, m.Priority)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, m.Tags)
|
||||
require.Equal(t, "https://ntfy.sh", m.Click)
|
||||
require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL)
|
||||
require.Equal(t, "fdroid.apk", m.Attachment.Name)
|
||||
require.Equal(t, int64(0), m.Attachment.Size)
|
||||
require.Equal(t, "", m.Attachment.Owner)
|
||||
require.Equal(t, int64(0), m.Attachment.Expires)
|
||||
require.Equal(t, "", m.Attachment.Type)
|
||||
}
|
246
cmd/serve.go
Normal file
@ -0,0 +1,246 @@
|
||||
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"
|
||||
"net"
|
||||
"strings"
|
||||
"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: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||
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: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", 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: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
|
||||
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.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-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,
|
||||
Category: categoryServer,
|
||||
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")
|
||||
listenUnix := c.String("listen-unix")
|
||||
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")
|
||||
authFile := c.String("auth-file")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
webRoot := c.String("web-root")
|
||||
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")
|
||||
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||
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")
|
||||
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
return errors.New("if set, base-url must start with http:// or https://")
|
||||
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
} else if !util.InStringList([]string{"app", "home"}, webRoot) {
|
||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||
}
|
||||
|
||||
// Default auth permissions
|
||||
webRootIsApp := webRoot == "app"
|
||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||
|
||||
// Special case: Unset default
|
||||
if listenHTTP == "-" {
|
||||
listenHTTP = ""
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||
}
|
||||
|
||||
// Resolve hosts
|
||||
visitorRequestLimitExemptIPs := make([]string, 0)
|
||||
for _, host := range visitorRequestLimitExemptHosts {
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||
continue
|
||||
}
|
||||
for _, ip := range ips {
|
||||
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Run server
|
||||
conf := server.NewConfig()
|
||||
conf.BaseURL = baseURL
|
||||
conf.ListenHTTP = listenHTTP
|
||||
conf.ListenHTTPS = listenHTTPS
|
||||
conf.ListenUnix = listenUnix
|
||||
conf.KeyFile = keyFile
|
||||
conf.CertFile = certFile
|
||||
conf.FirebaseKeyFile = firebaseKeyFile
|
||||
conf.CacheFile = cacheFile
|
||||
conf.CacheDuration = cacheDuration
|
||||
conf.AuthFile = authFile
|
||||
conf.AuthDefaultRead = authDefaultRead
|
||||
conf.AuthDefaultWrite = authDefaultWrite
|
||||
conf.AttachmentCacheDir = attachmentCacheDir
|
||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.WebRootIsApp = webRootIsApp
|
||||
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.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
||||
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
|
||||
}
|
77
cmd/serve_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/util"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
go func() {
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile})
|
||||
require.Nil(t, err)
|
||||
}()
|
||||
for i := 0; i < 40 && !util.FileExists(sockFile); i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
require.True(t, util.FileExists(sockFile))
|
||||
|
||||
cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic")
|
||||
out, err := cmd.Output()
|
||||
require.Nil(t, err)
|
||||
m := toMessage(t, string(out))
|
||||
require.Equal(t, "this is a message", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Serve_WebSocket(t *testing.T) {
|
||||
port := 10000 + rand.Intn(20000)
|
||||
go func() {
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)})
|
||||
require.Nil(t, err)
|
||||
}()
|
||||
test.WaitForPortUp(t, port)
|
||||
|
||||
ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil)
|
||||
require.Nil(t, err)
|
||||
|
||||
messageType, data, err := ws.ReadMessage()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, websocket.TextMessage, messageType)
|
||||
require.Equal(t, "open", toMessage(t, string(data)).Event)
|
||||
|
||||
c := client.New(client.NewConfig())
|
||||
_, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message")
|
||||
require.Nil(t, err)
|
||||
|
||||
messageType, data, err = ws.ReadMessage()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, websocket.TextMessage, messageType)
|
||||
|
||||
m := toMessage(t, string(data))
|
||||
require.Equal(t, "my message", m.Message)
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
}
|
||||
|
||||
func newEmptyFile(t *testing.T) string {
|
||||
filename := filepath.Join(t.TempDir(), "empty")
|
||||
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
||||
return filename
|
||||
}
|
261
cmd/subscribe.go
Normal file
@ -0,0 +1,261 @@
|
||||
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,
|
||||
Category: categoryClient,
|
||||
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.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
|
||||
&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 sub -u phil:mypass secret # Subscribe with username/password
|
||||
|
||||
ntfy subscribe TOPIC COMMAND
|
||||
This executes COMMAND for every incoming messages. The message fields are passed to the
|
||||
command as environment variables:
|
||||
|
||||
Variable Aliases Description
|
||||
--------------- --------------------- -----------------------------------
|
||||
$NTFY_ID $id Unique message ID
|
||||
$NTFY_TIME $time Unix timestamp of the message delivery
|
||||
$NTFY_TOPIC $topic Topic name
|
||||
$NTFY_MESSAGE $message, $m Message body
|
||||
$NTFY_TITLE $title, $t Message title
|
||||
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
|
||||
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
|
||||
$NTFY_RAW $raw Raw JSON message
|
||||
|
||||
Examples:
|
||||
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
||||
ntfy sub topic1 /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")
|
||||
user := c.String("user")
|
||||
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 user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
user = parts[0]
|
||||
pass = parts[1]
|
||||
} else {
|
||||
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||
p, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass = string(p)
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
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))
|
||||
}
|
||||
if s.User != "" && s.Password != "" {
|
||||
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
|
||||
}
|
||||
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
|
||||
}
|
272
cmd/user.go
Normal file
@ -0,0 +1,272 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var flagsUser = userCommandFlags()
|
||||
var cmdUser = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSource("config", flagsUser),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Adds a new user",
|
||||
UsageText: "ntfy user add [--role=admin|user] USERNAME",
|
||||
Action: execUserAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
||||
},
|
||||
Description: `Add a new user to the ntfy user database.
|
||||
|
||||
A user can be either a regular user, or an admin. A regular user has no read or write access (unless
|
||||
granted otherwise by the auth-default-access setting). An admin user has read and write access to all
|
||||
topics.
|
||||
|
||||
Examples:
|
||||
ntfy user add phil # Add regular user phil
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a user",
|
||||
UsageText: "ntfy user remove USERNAME",
|
||||
Action: execUserDel,
|
||||
Description: `Remove a user from the ntfy user database.
|
||||
|
||||
Example:
|
||||
ntfy user del phil
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change-pass",
|
||||
Aliases: []string{"chp"},
|
||||
Usage: "Changes a user's password",
|
||||
UsageText: "ntfy user change-pass USERNAME",
|
||||
Action: execUserChangePass,
|
||||
Description: `Change the password for the given user.
|
||||
|
||||
The new password will be read from STDIN, and it'll be confirmed by typing
|
||||
it twice.
|
||||
|
||||
Example:
|
||||
ntfy user change-pass phil
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change-role",
|
||||
Aliases: []string{"chr"},
|
||||
Usage: "Changes the role of a user",
|
||||
UsageText: "ntfy user change-role USERNAME ROLE",
|
||||
Action: execUserChangeRole,
|
||||
Description: `Change the role for the given user to admin or user.
|
||||
|
||||
This command can be used to change the role of a user either from a regular user
|
||||
to an admin user, or the other way around:
|
||||
|
||||
- admin: an admin has read/write access to all topics
|
||||
- user: a regular user only has access to what was explicitly granted via 'ntfy access'
|
||||
|
||||
When changing the role of a user to "admin", all access control entries for that
|
||||
user are removed, since they are no longer necessary.
|
||||
|
||||
Example:
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
ntfy user change-role phil user # Remove admin role from user phil
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of users",
|
||||
Action: execUserList,
|
||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||
|
||||
This is a server-only command. It directly reads from the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
This command is an alias to calling 'ntfy access' (display access control list).
|
||||
`,
|
||||
},
|
||||
},
|
||||
Description: `Manage users of the ntfy server.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||
to the related command 'ntfy access'.
|
||||
|
||||
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||
passwords or roles.
|
||||
|
||||
Examples:
|
||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||
ntfy user add phil # Add regular user phil
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user del phil # Delete user phil
|
||||
ntfy user change-pass phil # Change password for user phil
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
`,
|
||||
}
|
||||
|
||||
func execUserAdd(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
role := auth.Role(c.String("role"))
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||
} else if username == userEveryone {
|
||||
return errors.New("username not allowed")
|
||||
} else if !auth.AllowedRole(role) {
|
||||
return errors.New("role must be either 'user' or 'admin'")
|
||||
}
|
||||
manager, err := createAuthManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user, _ := manager.User(username); user != nil {
|
||||
return fmt.Errorf("user %s already exists", username)
|
||||
}
|
||||
password, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manager.AddUser(username, password, role); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserDel(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user del --help' for help")
|
||||
} else if username == userEveryone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createAuthManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if err := manager.RemoveUser(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangePass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
||||
} else if username == userEveryone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createAuthManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
password, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manager.ChangePassword(username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangeRole(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
role := auth.Role(c.Args().Get(1))
|
||||
if username == "" || !auth.AllowedRole(role) {
|
||||
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
||||
} else if username == userEveryone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createAuthManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if err := manager.ChangeRole(username, role); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserList(c *cli.Context) error {
|
||||
manager, err := createAuthManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := manager.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return showUsers(c, manager, users)
|
||||
}
|
||||
|
||||
func createAuthManager(c *cli.Context) (auth.Manager, error) {
|
||||
authFile := c.String("auth-file")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
if authFile == "" {
|
||||
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
||||
} else if !util.FileExists(authFile) {
|
||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
|
||||
}
|
||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||
return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
|
||||
}
|
||||
|
||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||
fmt.Fprint(c.App.ErrWriter, "password: ")
|
||||
password, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25))
|
||||
confirm, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25))
|
||||
if subtle.ConstantTimeCompare(confirm, password) != 1 {
|
||||
return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly")
|
||||
}
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
func userCommandFlags() []cli.Flag {
|
||||
return []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: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
}
|
||||
}
|
145
cmd/user_test.go
Normal file
@ -0,0 +1,145 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_User_Add(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
app, stdin, _, _ = newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
err := runUserCommand(app, conf, "add", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "user phil already exists")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Admin(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nNOTMATCH")
|
||||
err := runUserCommand(app, conf, "add", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly")
|
||||
}
|
||||
|
||||
func TestCLI_User_ChangePass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
// Change pass
|
||||
app, stdin, _, stderr = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
||||
require.Contains(t, stderr.String(), "changed password for user phil")
|
||||
}
|
||||
|
||||
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
// Change role
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Delete(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
// Delete user
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil removed")
|
||||
|
||||
// Delete user again (does not exist)
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "del", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "user phil does not exist")
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||
conf = server.NewConfig()
|
||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||
conf.AuthDefaultRead = false
|
||||
conf.AuthDefaultWrite = false
|
||||
s, port = test.StartServerWithConfig(t, conf)
|
||||
return
|
||||
}
|
||||
|
||||
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"user",
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + confToDefaultAccess(conf),
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
|
||||
func confToDefaultAccess(conf *server.Config) string {
|
||||
var defaultAccess string
|
||||
if conf.AuthDefaultRead && conf.AuthDefaultWrite {
|
||||
defaultAccess = "read-write"
|
||||
} else if conf.AuthDefaultRead && !conf.AuthDefaultWrite {
|
||||
defaultAccess = "read-only"
|
||||
} else if !conf.AuthDefaultRead && conf.AuthDefaultWrite {
|
||||
defaultAccess = "write-only"
|
||||
} else if !conf.AuthDefaultRead && !conf.AuthDefaultWrite {
|
||||
defaultAccess = "deny-all"
|
||||
}
|
||||
return defaultAccess
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
// Package config provides the main configuration
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defines default config settings
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultKeepaliveInterval = 30 * time.Second
|
||||
DefaultManagerInterval = time.Minute
|
||||
)
|
||||
|
||||
// Defines all the limits
|
||||
// - global topic limit: max number of topics overall
|
||||
// - per visistor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
|
||||
// - per visistor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||
const (
|
||||
DefaultGlobalTopicLimit = 5000
|
||||
DefaultVisitorRequestLimitBurst = 60
|
||||
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
||||
DefaultVisitorSubscriptionLimit = 30
|
||||
)
|
||||
|
||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||
type Config struct {
|
||||
ListenHTTP string
|
||||
ListenHTTPS string
|
||||
KeyFile string
|
||||
CertFile string
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
GlobalTopicLimit int
|
||||
VisitorRequestLimitBurst int
|
||||
VisitorRequestLimitReplenish time.Duration
|
||||
VisitorSubscriptionLimit int
|
||||
BehindProxy bool
|
||||
}
|
||||
|
||||
// New instantiates a default new config
|
||||
func New(listenHTTP string) *Config {
|
||||
return &Config{
|
||||
ListenHTTP: listenHTTP,
|
||||
ListenHTTPS: "",
|
||||
KeyFile: "",
|
||||
CertFile: "",
|
||||
FirebaseKeyFile: "",
|
||||
CacheFile: "",
|
||||
CacheDuration: DefaultCacheDuration,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
GlobalTopicLimit: DefaultGlobalTopicLimit,
|
||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||
BehindProxy: false,
|
||||
}
|
||||
}
|
@ -1,71 +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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
798
docs/config.md
@ -1,21 +1,70 @@
|
||||
# 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/server/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).
|
||||
|
||||
## Example config
|
||||
!!! info
|
||||
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
|
||||
It contains examples and detailed descriptions of all the settings.
|
||||
|
||||
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
|
||||
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
|
||||
|
||||
Here are a few working sample configs:
|
||||
|
||||
=== "server.yml (HTTP-only, with cache + attachments)"
|
||||
``` yaml
|
||||
base-url: "http://ntfy.example.com"
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
=== "server.yml (HTTP+HTTPS, with cache + attachments)"
|
||||
``` yaml
|
||||
base-url: "http://ntfy.example.com"
|
||||
listen-http: ":80"
|
||||
listen-https: ":443"
|
||||
key-file: "/etc/letsencrypt/live/ntfy.example.com.key"
|
||||
cert-file: "/etc/letsencrypt/live/ntfy.example.com.crt"
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
=== "server.yml (ntfy.sh config)"
|
||||
``` yaml
|
||||
# All the things: Behind a proxy, Firebase, cache, attachments,
|
||||
# SMTP publishing & receiving
|
||||
|
||||
base-url: "https://ntfy.sh"
|
||||
listen-http: "127.0.0.1:2586"
|
||||
firebase-key-file: "/etc/ntfy/firebase.json"
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
behind-proxy: true
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
|
||||
smtp-sender-user: "AKIDEADBEEFAFFE12345"
|
||||
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
|
||||
smtp-sender-from: "ntfy@ntfy.sh"
|
||||
smtp-server-listen: ":25"
|
||||
smtp-server-domain: "ntfy.sh"
|
||||
smtp-server-addr-prefix: "ntfy-"
|
||||
keepalive-interval: "45s"
|
||||
```
|
||||
|
||||
## Message cache
|
||||
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
|
||||
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
|
||||
@ -26,24 +75,465 @@ restart**. You can override this behavior using the following config settings:
|
||||
|
||||
* `cache-file`: if set, ntfy will store messages in a SQLite based cache (default is empty, which means in-memory cache).
|
||||
**This is required if you'd like messages to be retained across restarts**.
|
||||
* `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`)
|
||||
* `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`).
|
||||
|
||||
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#polling), as well as the
|
||||
[`since=` parameter](subscribe/api.md#fetching-cached-messages).
|
||||
You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only
|
||||
passed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward
|
||||
the message to the subscribers.
|
||||
|
||||
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
|
||||
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
||||
|
||||
## Attachments
|
||||
If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
|
||||
this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`).
|
||||
Once these options are set and the directory is writable by the server user, you can upload attachments via PUT.
|
||||
|
||||
By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues
|
||||
and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download
|
||||
feature) to download the file. The following config options are relevant to attachments:
|
||||
|
||||
* `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs
|
||||
* `attachment-cache-dir` is the cache directory for attached files
|
||||
* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G)
|
||||
* `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M)
|
||||
* `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h)
|
||||
|
||||
Here's an example config using mostly the defaults (except for the cache directory, which is empty by default):
|
||||
|
||||
=== "/etc/ntfy/server.yml (minimal)"
|
||||
``` yaml
|
||||
base-url: "https://ntfy.sh"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
=== "/etc/ntfy/server.yml (all options)"
|
||||
``` yaml
|
||||
base-url: "https://ntfy.sh"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
attachment-total-size-limit: "5G"
|
||||
attachment-file-size-limit: "15M"
|
||||
attachment-expiry-duration: "3h"
|
||||
visitor-attachment-total-size-limit: "100M"
|
||||
visitor-attachment-daily-bandwidth-limit: "500M"
|
||||
```
|
||||
|
||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit`
|
||||
and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.
|
||||
|
||||
## Access control
|
||||
By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how
|
||||
ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization.
|
||||
|
||||
ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles
|
||||
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
|
||||
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
|
||||
|
||||
To set up auth, simply **configure the following two options**:
|
||||
|
||||
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
|
||||
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
|
||||
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
|
||||
set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
|
||||
|
||||
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command
|
||||
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
|
||||
commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user
|
||||
accessing them has the right permissions.
|
||||
|
||||
### Users and roles
|
||||
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
|
||||
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
|
||||
|
||||
**Roles:**
|
||||
|
||||
* Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access`
|
||||
(see [below](#access-control-list-acl)).
|
||||
* Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary.
|
||||
|
||||
**Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details):
|
||||
|
||||
```
|
||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||
ntfy user add phil # Add regular user phil
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user del phil # Delete user phil
|
||||
ntfy user change-pass phil # Change password for user phil
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
```
|
||||
|
||||
### Access control list (ACL)
|
||||
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
|
||||
Each entry represents the access permissions for a user to a specific topic or topic pattern.
|
||||
|
||||
The ACL can be displayed or modified with the `ntfy access` command:
|
||||
|
||||
```
|
||||
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||
ntfy access USERNAME # Shows access control entries for USERNAME
|
||||
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
|
||||
```
|
||||
|
||||
A `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the
|
||||
anonymous user `everyone` or `*`, which represents clients that access the API without username/password.
|
||||
|
||||
A `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any
|
||||
number of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any
|
||||
number of characters.
|
||||
|
||||
A `PERMISSION` is any of the following supported permissions:
|
||||
|
||||
* `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as
|
||||
[subscribing](subscribe/api.md) and reading messages
|
||||
* `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic
|
||||
* `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it
|
||||
* `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic
|
||||
|
||||
**Example commands** (type `ntfy access --help` for more details):
|
||||
```
|
||||
ntfy access # Shows entire access control list
|
||||
ntfy access phil # Shows access for user phil
|
||||
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
|
||||
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
|
||||
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
|
||||
ntfy access --reset # Reset entire access control list
|
||||
ntfy access --reset phil # Reset all access for user phil
|
||||
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
|
||||
```
|
||||
|
||||
**Example ACL:**
|
||||
```
|
||||
$ ntfy access
|
||||
user phil (admin)
|
||||
- read-write access to all topics (admin role)
|
||||
user ben (user)
|
||||
- read-write access to topic garagedoor
|
||||
- read-write access to topic alerts*
|
||||
- read-only access to topic furnace
|
||||
user * (anonymous)
|
||||
- read-only access to topic announcements
|
||||
- read-only access to topic server-stats
|
||||
- no access to any (other) topics (server config)
|
||||
```
|
||||
|
||||
In this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary).
|
||||
User `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access
|
||||
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||
|
||||
### Example: Private instance
|
||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file "/var/lib/ntfy/user.db"
|
||||
auth-default-access: "deny-all"
|
||||
```
|
||||
|
||||
After that, simply create an `admin` user:
|
||||
|
||||
```
|
||||
$ ntfy user add --role=admin phil
|
||||
password: mypass
|
||||
confirm: mypass
|
||||
user phil added with role admin
|
||||
```
|
||||
|
||||
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-u phil:mypass \
|
||||
-d "Look ma, with auth" \
|
||||
https://ntfy.example.com/mysecrets
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
-u phil:mypass \
|
||||
ntfy.example.com/mysecrets \
|
||||
"Look ma, with auth"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mysecrets HTTP/1.1
|
||||
Host: ntfy.example.com
|
||||
Authorization: Basic cGhpbDpteXBhc3M=
|
||||
|
||||
Look ma, with auth
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.example.com/mysecrets', {
|
||||
method: 'POST', // PUT works too
|
||||
body: 'Look ma, with auth',
|
||||
headers: {
|
||||
'Authorization': 'Basic cGhpbDpteXBhc3M='
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
|
||||
strings.NewReader("Look ma, with auth"))
|
||||
req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.example.com/mysecrets",
|
||||
data="Look ma, with auth",
|
||||
headers={
|
||||
"Authorization": "Basic cGhpbDpteXBhc3M="
|
||||
})
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST', // PUT also works
|
||||
'header' =>
|
||||
'Content-Type: text/plain\r\n' .
|
||||
'Authorization: Basic cGhpbDpteXBhc3M=',
|
||||
'content' => 'Look ma, with auth'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
## E-mail notifications
|
||||
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
|
||||
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
|
||||
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
|
||||
|
||||
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
|
||||
following settings:
|
||||
|
||||
* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer
|
||||
* `smtp-sender-addr` is the hostname:port of the SMTP server
|
||||
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
|
||||
* `smtp-sender-from` is the e-mail address of the sender
|
||||
|
||||
Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is
|
||||
configured for `ntfy.sh`):
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
base-url: "https://ntfy.sh"
|
||||
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
|
||||
smtp-sender-user: "AKIDEADBEEFAFFE12345"
|
||||
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
|
||||
smtp-sender-from: "ntfy@ntfy.sh"
|
||||
```
|
||||
|
||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
||||
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
||||
|
||||
## E-mail publishing
|
||||
To allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured,
|
||||
users can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or
|
||||
`myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for
|
||||
statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||
|
||||
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
||||
|
||||
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
||||
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
|
||||
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
||||
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
||||
accepted (which may obviously be a spam problem).
|
||||
|
||||
Here's an example config (this is how it is configured for `ntfy.sh`):
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
smtp-server-listen: ":25"
|
||||
smtp-server-domain: "ntfy.sh"
|
||||
smtp-server-addr-prefix: "ntfy-"
|
||||
```
|
||||
|
||||
In addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record)
|
||||
and a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is
|
||||
configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
||||
|
||||
<figure markdown>
|
||||
![DNS records for incoming mail](static/img/screenshot-email-publishing-dns.png){ width=600 }
|
||||
<figcaption>DNS records for incoming mail</figcaption>
|
||||
</figure>
|
||||
|
||||
## Behind a proxy (TLS, etc.)
|
||||
|
||||
!!! warning
|
||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise all visitors are rate limited
|
||||
as if they are one.
|
||||
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.
|
||||
|
||||
**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
|
||||
It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates
|
||||
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
||||
Whatever your reasons may be, there are a few things to consider.
|
||||
|
||||
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
||||
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
|
||||
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
||||
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
||||
|
||||
**TLS/SSL:** ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
|
||||
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself.
|
||||
=== "/etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
||||
behind-proxy: true
|
||||
```
|
||||
|
||||
### TLS/SSL
|
||||
ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
|
||||
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).
|
||||
|
||||
I highly recommend using [certbot](https://certbot.eff.org/). I use it with the [dns-route53 plugin](https://certbot-dns-route53.readthedocs.io/en/stable/),
|
||||
which lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challenge. That's much easier than using the
|
||||
HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to
|
||||
be incredibly helpful.
|
||||
|
||||
### nginx/Apache2/caddy
|
||||
For your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets**
|
||||
by forwarding the `Connection` and `Upgrade` headers accordingly.
|
||||
|
||||
In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
||||
or the root domain:
|
||||
|
||||
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
server_name ntfy.sh;
|
||||
|
||||
location / {
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
set $redirect_https "";
|
||||
if ($request_method = GET) {
|
||||
set $redirect_https "yes";
|
||||
}
|
||||
if ($request_uri ~* "^/([-_a-z0-9]{0,64}$|docs/|static/)") {
|
||||
set $redirect_https "${redirect_https}yes";
|
||||
}
|
||||
if ($redirect_https = "yesyes") {
|
||||
return 302 https://$http_host$request_uri$is_args$query_string;
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:2586;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_connect_timeout 3m;
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ntfy.sh;
|
||||
|
||||
ssl_session_cache builtin:1000 shared:SSL:10m;
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:2586;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_connect_timeout 3m;
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "Apache2 (/etc/apache2/sites-*/ntfy.conf)"
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName ntfy.sh
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName ntfy.sh
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
</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, http://nfty.sh {
|
||||
reverse_proxy 127.0.0.1:2586
|
||||
}
|
||||
```
|
||||
|
||||
## Firebase (FCM)
|
||||
!!! info
|
||||
@ -61,7 +551,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:
|
||||
@ -75,80 +565,260 @@ 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)):
|
||||
|
||||
Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`).
|
||||
After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one
|
||||
request every 10s (defined by `visitor-request-limit-replenish`)
|
||||
request every 5s (defined by `visitor-request-limit-replenish`)
|
||||
|
||||
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
|
||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
||||
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
||||
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
||||
|
||||
### Attachment limits
|
||||
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||
per-visitor limits:
|
||||
|
||||
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.
|
||||
* `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,
|
||||
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
|
||||
This limit is typically called `nofile`. Other than that, RAM and CPU are obviously relevant. You may also want to check
|
||||
out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/how_many_actively_connected_http_clients_can_a_go/).
|
||||
|
||||
Depending on *how you run it*, here are a few limits that are relevant:
|
||||
|
||||
### For systemd services
|
||||
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
||||
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
|
||||
by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf`
|
||||
is not relevant.
|
||||
|
||||
=== "/etc/systemd/system/ntfy.service.d/override.conf"
|
||||
```
|
||||
# Allow 20,000 ntfy connections (and give room for other file handles)
|
||||
[Service]
|
||||
LimitNOFILE=20500
|
||||
```
|
||||
|
||||
### Outside of systemd
|
||||
If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to
|
||||
increase the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting
|
||||
by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`.
|
||||
|
||||
=== "/etc/security/limits.conf"
|
||||
```
|
||||
# Increase open files limit globally
|
||||
* hard nofile 20500
|
||||
```
|
||||
|
||||
### Proxy limits (nginx, Apache2)
|
||||
If you are running [behind a proxy](#behind-a-proxy-tls-etc) (e.g. nginx, Apache), the open files limit of the proxy is also
|
||||
relevant. So if your proxy runs inside of systemd, increase the limits in systemd for the proxy. Typically, the proxy
|
||||
open files limit has to be **double the number of how many connections you'd like to support**, because the proxy has
|
||||
to maintain the client connection and the connection to ntfy.
|
||||
|
||||
=== "/etc/nginx/nginx.conf"
|
||||
```
|
||||
events {
|
||||
# Allow 40,000 proxy connections (2x of the desired ntfy connection count;
|
||||
# and give room for other file handles)
|
||||
worker_connections 40500;
|
||||
}
|
||||
```
|
||||
|
||||
=== "/etc/systemd/system/nginx.service.d/override.conf"
|
||||
```
|
||||
# Allow 40,000 proxy connections (2x of the desired ntfy connection count;
|
||||
# and give room for other file handles)
|
||||
[Service]
|
||||
LimitNOFILE=40500
|
||||
```
|
||||
|
||||
### Banning bad actors (fail2ban)
|
||||
If you put stuff on the Internet, bad actors will try to break them or break in. [fail2ban](https://www.fail2ban.org/)
|
||||
and nginx's [ngx_http_limit_req_module module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html) can be used
|
||||
to ban client IPs if they misbehave. This is on top of the [rate limiting](#rate-limiting) inside the ntfy server.
|
||||
|
||||
Here's an example for how ntfy.sh is configured, following the instructions from two tutorials ([here](https://easyengine.io/tutorials/nginx/fail2ban/)
|
||||
and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-attack/)):
|
||||
|
||||
=== "/etc/nginx/nginx.conf"
|
||||
```
|
||||
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. |
|
||||
| `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`. |
|
||||
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||
| `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. |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
||||
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
||||
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
||||
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
|
||||
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
|
||||
| `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* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||
| `visitor-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]
|
||||
CATEGORY:
|
||||
Server commands
|
||||
|
||||
Try 'ntfy COMMAND --help' for more information.
|
||||
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
|
||||
|
||||
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]
|
||||
--listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||
--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]
|
||||
--auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--auth-default-access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--attachment-cache-dir value 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: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT]
|
||||
--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: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-exempt-hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-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)
|
||||
```
|
||||
|
||||
|
41
docs/deprecations.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 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
|
||||
|
||||
### Android app: WebSockets will become the default connection protocol
|
||||
> Active since 2022-03-13, behavior will change in **June 2022**
|
||||
|
||||
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
|
||||
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
|
||||
|
||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||
> Active since 2022-02-27, behavior will change in **May 2022**
|
||||
|
||||
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
|
||||
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
||||
|
||||
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||
|
||||
## Previous deprecations
|
||||
|
||||
### Running server via `ntfy` (instead of `ntfy serve`)
|
||||
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||
|
||||
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
||||
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
||||
just the server.
|
||||
|
||||
=== "Before"
|
||||
```
|
||||
$ ntfy
|
||||
2021/12/17 08:16:01 Listening on :80/http
|
||||
```
|
||||
|
||||
=== "After"
|
||||
```
|
||||
$ ntfy serve
|
||||
2021/12/17 08:16:01 Listening on :80/http
|
||||
```
|
||||
|
@ -16,6 +16,27 @@ rsync -a root@laptop /backups/laptop \
|
||||
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||
```
|
||||
|
||||
## Low disk space alerts
|
||||
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
|
||||
effective.
|
||||
|
||||
``` bash
|
||||
#!/bin/bash
|
||||
|
||||
mingigs=10
|
||||
avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }')
|
||||
topicurl=https://ntfy.sh/mytopic
|
||||
|
||||
if [ -n "$avail" ]; then
|
||||
curl \
|
||||
-d "Only $avail GB available on the root disk. Better clean that up." \
|
||||
-H "Title: Low disk space alert on $(hostname)" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: warning,cd" \
|
||||
$topicurl
|
||||
fi
|
||||
```
|
||||
|
||||
## Server-sent messages in your web app
|
||||
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
||||
web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
|
||||
@ -64,5 +85,48 @@ It looked something like this:
|
||||
curl -d "$(hostname),$count,$time" ntfy.sh/results
|
||||
```
|
||||
|
||||
## Ansible, Salt and Puppet
|
||||
You can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated.
|
||||
One of my co-workers uses the following Ansible task to let him know when things are done:
|
||||
|
||||
```yml
|
||||
- name: Send ntfy.sh update
|
||||
uri:
|
||||
url: "https://ntfy.sh/{{ ntfy_channel }}"
|
||||
method: POST
|
||||
body: "{{ inventory_hostname }} reseeding complete"
|
||||
```
|
||||
|
||||
## Watchtower notifications (shoutrrr)
|
||||
You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
|
||||
|
||||
Example docker-compose.yml:
|
||||
```yml
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
environment:
|
||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||
```
|
||||
|
||||
Or, if you only want to send notifications using shoutrrr:
|
||||
```
|
||||
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||
```
|
||||
|
||||
## Random cronjobs
|
||||
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||
|
||||
``` cron
|
||||
# Check github/ntfy user
|
||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||
~
|
||||
```
|
||||
|
||||
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
|
||||
|
||||
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||
|
||||
Some simple bash scripts to achieve this are available <a href="https://github.com/nickexyz/ntfy-shellscripts">here</a>
|
||||
|
@ -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?
|
||||
@ -33,10 +33,11 @@ If you do not care for Firebase, I suggest you install the [F-Droid version](htt
|
||||
of the app and [self-host your own ntfy server](install.md).
|
||||
|
||||
## How much battery does the Android app use?
|
||||
If you use the ntfy.sh server and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 4% of
|
||||
battery in 17h of use (on my phone). I use it, and it makes no difference to me.
|
||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of
|
||||
battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||
decent now.
|
||||
|
||||
## What is instant delivery?
|
||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||
|
@ -22,14 +22,20 @@ For this guide, we'll just use `mytopic` as our topic name:
|
||||
That's it. After you tap "Subscribe", the app is listening for new messages on that topic.
|
||||
|
||||
## Step 2: Send a message
|
||||
Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT or POST. The message
|
||||
is in the request body. Here's an example showing how to publish a simple message using a POST request:
|
||||
Now let's [send a message](publish.md) to our topic. It's easy in every language, since we're just using HTTP PUT/POST,
|
||||
or with the [ntfy CLI](install.md). The message is in the request body. Here's an example showing how to publish a
|
||||
simple message using a POST request:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl -d "Backup successful 😀" ntfy.sh/mytopic
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish mytopic "Backup successful 😀"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mytopic HTTP/1.1
|
||||
@ -52,6 +58,12 @@ is in the request body. Here's an example showing how to publish a simple messag
|
||||
strings.NewReader("Backup successful 😀"))
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/mytopic",
|
||||
data="Backup successful 😀".encode(encoding='utf-8'))
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
|
||||
@ -66,7 +78,7 @@ is in the request body. Here's an example showing how to publish a simple messag
|
||||
This will create a notification that looks like this:
|
||||
|
||||
<figure markdown>
|
||||
![basic notification](static/img/basic-notification.png){ width=500 }
|
||||
![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }
|
||||
<figcaption>Android notification</figcaption>
|
||||
</figure>
|
||||
|
||||
@ -76,7 +88,7 @@ That's it. You're all set. Go play and read the rest of the docs. I highly recom
|
||||
Here's another video showing the entire process:
|
||||
|
||||
<figure>
|
||||
<video controls muted autoplay loop width="650" src="static/img/overview.mp4"></video>
|
||||
<video controls muted autoplay loop width="650" src="static/img/android-video-overview.mp4"></video>
|
||||
<figcaption>Sending push notifications to your Android phone</figcaption>
|
||||
</figure>
|
||||
|
||||
|
@ -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,29 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_x86_64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.18.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.18.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.18.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.18.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.18.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.18.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
@ -82,7 +94,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@ -90,7 +102,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@ -98,7 +110,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@ -108,36 +120,50 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.0/ntfy_1.5.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_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.0/ntfy_1.5.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_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.0/ntfy_1.5.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
## Arch Linux
|
||||
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
|
||||
```
|
||||
paru -S ntfysh-bin
|
||||
```
|
||||
|
||||
Alternatively, run the following commands to install ntfy manually:
|
||||
```
|
||||
curl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv
|
||||
cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
|
||||
## 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 +173,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
|
||||
|
@ -38,7 +38,7 @@ Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup Successful 😀" -UseBasicParsing
|
||||
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@ -126,7 +126,7 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/phil_alerts"
|
||||
$headers = @{ Title="Unauthorized access detected"
|
||||
Priority="Urgent"
|
||||
Priority="urgent"
|
||||
Tags="warning,skull" }
|
||||
$body = "Remote access to phils-laptop detected. Act right away."
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
@ -245,13 +245,13 @@ notification sounds and vibration patterns on your phone to map to these priorit
|
||||
|
||||
The following priorities exist:
|
||||
|
||||
| Priority | Icon | ID | Name | Description |
|
||||
|---|---|---|---|---|
|
||||
| Max priority | ![min priority](static/img/priority-5.svg) | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. |
|
||||
| High priority | ![min priority](static/img/priority-4.svg) | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. |
|
||||
| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. |
|
||||
| Low priority | ![min priority](static/img/priority-2.svg) |`2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |
|
||||
| Min priority | ![min priority](static/img/priority-1.svg) | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". |
|
||||
| Priority | Icon | ID | Name | Description |
|
||||
|----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------|
|
||||
| Max priority | ![min priority](static/img/priority-5.svg) | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. |
|
||||
| High priority | ![min priority](static/img/priority-4.svg) | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. |
|
||||
| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. |
|
||||
| Low priority | ![min priority](static/img/priority-2.svg) | `2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |
|
||||
| Min priority | ![min priority](static/img/priority-1.svg) | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". |
|
||||
|
||||
You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`).
|
||||
|
||||
@ -297,7 +297,7 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/phil_alerts"
|
||||
$headers = @{ Priority="Urgent" }
|
||||
$headers = @{ Priority="5" }
|
||||
$body = "An urgent message"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
```
|
||||
@ -1117,13 +1117,13 @@ that, your IP address appears in the e-mail body. This is to prevent abuse.
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/alerts"
|
||||
$headers = @{ Title"="Low disk space alert"
|
||||
Priority=4
|
||||
Priority="high"
|
||||
Tags="warning,skull,backup-host,ssh-login")
|
||||
Email="phil@example.com" }
|
||||
$body = "Unknown login from 5.31.23.83 to backups.example.com"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing
|
||||
```
|
||||
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/alerts",
|
||||
@ -1237,8 +1237,7 @@ Here's a simple example:
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.example.com/mysecrets"
|
||||
$basicAuthValue = "Basic [user:pass-bese64encoded]"
|
||||
$headers = @{ Authorization=$basicAuthValue }
|
||||
$headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" }
|
||||
$body = "Look ma, with auth"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||
```
|
||||
@ -1405,7 +1404,7 @@ to `no`. This will instruct the server not to forward messages to Firebase.
|
||||
$body = "This message won't be forwarded to FCM"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||
```
|
||||
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/mytopic",
|
||||
|
262
docs/releases.md
Normal file
@ -0,0 +1,262 @@
|
||||
# Release notes
|
||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
<!--
|
||||
## ntfy Android app v1.10.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))
|
||||
* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)
|
||||
|
||||
## ntfy server v1.19.0 (UNRELEASED)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix install instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
|
||||
|
||||
-->
|
||||
|
||||
## ntfy server v1.18.0
|
||||
Released Mar 16, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133),
|
||||
thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and
|
||||
[@Fallenbagel](https://github.com/Fallenbagel) for testing)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting)
|
||||
* Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert))
|
||||
* Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares))
|
||||
|
||||
**Deprecations:**
|
||||
|
||||
* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md)
|
||||
|
||||
## ntfy server v1.17.1
|
||||
Released Mar 12, 2022
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Replace `crypto.subtle` with `hashCode` to errors with Brave/FF-Windows (#157, thanks for reporting @arminus)
|
||||
|
||||
## ntfy server v1.17.0
|
||||
Released Mar 11, 2022
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* Replace [web app](https://ntfy.sh/app) with a React/MUI-based web app from the 21st century (#111)
|
||||
* Web UI broken with auth (#132, thanks for reporting @arminus)
|
||||
* Send static web resources as `Content-Encoding: gzip`, i.e. docs and web app (no ticket)
|
||||
* Add support for auth via `?auth=...` query param, used by WebSocket in web app (no ticket)
|
||||
|
||||
## ntfy server v1.16.0
|
||||
Released Feb 27, 2022
|
||||
|
||||
**Features & Bug fixes:**
|
||||
|
||||
* Add [auth support](https://ntfy.sh/docs/subscribe/cli/#authentication) for subscribing with CLI (#147/#148, thanks @lrabane)
|
||||
* Add support for [?since=<id>](https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages) (#151, thanks for reporting @nachotp)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Add [watchtower/shoutrr examples](https://ntfy.sh/docs/examples/#watchtower-notifications-shoutrrr) (#150, thanks @rogeliodh)
|
||||
* Add [release notes](https://ntfy.sh/docs/releases/)
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
* As of this release, message IDs will be 12 characters long (as opposed to 10 characters). This is to be able to
|
||||
distinguish them from Unix timestamps for #151.
|
||||
|
||||
## ntfy Android app v1.9.1
|
||||
Released Feb 16, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Share to topic feature (#131, thanks u/emptymatrix for reporting)
|
||||
* Ability to pick a default server (#127, thanks to @poblabs for reporting and testing)
|
||||
* Automatically delete notifications (#71, thanks @arjan-s for reporting)
|
||||
* Dark theme: Improvements around style and contrast (#119, thanks @kzshantonu for reporting)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Do not attempt to download attachments if they are already expired (#135)
|
||||
* Fixed crash in AddFragment as seen per stack trace in Play Console (no ticket)
|
||||
|
||||
**Other thanks:**
|
||||
|
||||
* Thanks to @rogeliodh, @cmeis and @poblabs for testing
|
||||
|
||||
## ntfy server v1.15.0
|
||||
Released Feb 14, 2022
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* Compress binaries with `upx` (#137)
|
||||
* Add `visitor-request-limit-exempt-hosts` to exempt friendly hosts from rate limits (#144)
|
||||
* Double default requests per second limit from 1 per 10s to 1 per 5s (no ticket)
|
||||
* Convert `\n` to new line for `X-Message` header as prep for sharing feature (see #136)
|
||||
* Reduce bcrypt cost to 10 to make auth timing more reasonable on slow servers (no ticket)
|
||||
* Docs update to include [public test topics](https://ntfy.sh/docs/publish/#public-topics) (no ticket)
|
||||
|
||||
## ntfy server v1.14.1
|
||||
Released Feb 9, 2022
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix ARMv8 Docker build (#113, thanks to @djmaze)
|
||||
* No other significant changes
|
||||
|
||||
## ntfy Android app v1.8.1
|
||||
Released Feb 6, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support [auth / access control](https://ntfy.sh/docs/config/#access-control) (#19, thanks to @cmeis, @drsprite/@poblabs,
|
||||
@gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||
* Export/upload log now allows censored/uncensored logs (no ticket)
|
||||
* Removed wake lock (except for notification dispatching, no ticket)
|
||||
* Swipe to remove notifications (#117)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob)
|
||||
* Fix for Android 12 crashes (#124, thanks @eskilop)
|
||||
* Fix WebSocket retry logic bug with multiple servers (no ticket)
|
||||
* Fix race in refresh logic leading to duplicate connections (no ticket)
|
||||
* Fix scrolling issue in subscribe to topic dialog (#131, thanks @arminus)
|
||||
* Fix base URL text field color in dark mode, and size with large fonts (no ticket)
|
||||
* Fix action bar color in dark mode (make black, no ticket)
|
||||
|
||||
**Notes:**
|
||||
|
||||
* Foundational work for per-subscription settings
|
||||
|
||||
## ntfy server v1.14.0
|
||||
Released Feb 3, 2022
|
||||
|
||||
**Features**:
|
||||
|
||||
* Server-side for [authentication & authorization](https://ntfy.sh/docs/config/#access-control) (#19, thanks for testing @cmeis, and for input from @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||
* Support `NTFY_TOPIC` env variable in `ntfy publish` (#103)
|
||||
|
||||
**Bug fixes**:
|
||||
|
||||
* Binary UnifiedPush messages should not be converted to attachments (part 1, #101)
|
||||
|
||||
**Docs**:
|
||||
|
||||
* Clarification regarding attachments (#118, thanks @xnumad)
|
||||
|
||||
## ntfy Android app v1.7.1
|
||||
Released Jan 21, 2022
|
||||
|
||||
**New features:**
|
||||
|
||||
* Battery improvements: wakelock disabled by default (#76)
|
||||
* Dark mode: Allow changing app appearance (#102)
|
||||
* Report logs: Copy/export logs to help troubleshooting (#94)
|
||||
* WebSockets (experimental): Use WebSockets to subscribe to topics (#96, #100, #97)
|
||||
* Show battery optimization banner (#105)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* (Partial) support for binary UnifiedPush messages (#101)
|
||||
|
||||
**Notes:**
|
||||
|
||||
* The foreground wakelock is now disabled by default
|
||||
* The service restarter is now scheduled every 3h instead of every 6h
|
||||
|
||||
## ntfy server v1.13.0
|
||||
Released Jan 16, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Websockets](https://ntfy.sh/docs/subscribe/api/#websockets) endpoint
|
||||
* Listen on Unix socket, see [config option](https://ntfy.sh/docs/config/#config-options) `listen-unix`
|
||||
|
||||
## ntfy Android app v1.6.0
|
||||
Released Jan 14, 2022
|
||||
|
||||
**New features:**
|
||||
|
||||
* Attachments: Send files to the phone (#25, #15)
|
||||
* Click action: Add a click action URL to notifications (#85)
|
||||
* Battery optimization: Allow disabling persistent wake-lock (#76, thanks @MatMaul)
|
||||
* Recognize imported user CA certificate for self-hosted servers (#87, thanks @keith24)
|
||||
* Remove mentions of "instant delivery" from F-Droid to make it less confusing (no ticket)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Subscription "muted until" was not always respected (#90)
|
||||
* Fix two stack traces reported by Play console vitals (no ticket)
|
||||
* Truncate FCM messages >4,000 bytes, prefer instant messages (#84)
|
||||
|
||||
## ntfy server v1.12.1
|
||||
Released Jan 14, 2022
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix security issue with attachment peaking (#93)
|
||||
|
||||
## ntfy server v1.12.0
|
||||
Released Jan 13, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Attachments](https://ntfy.sh/docs/publish/#attachments) (#25, #15)
|
||||
* [Click action](https://ntfy.sh/docs/publish/#click-action) (#85)
|
||||
* Increase FCM priority for high/max priority messages (#70)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Make postinst script work properly for rpm-based systems (#83, thanks @cmeis)
|
||||
* Truncate FCM messages longer than 4000 bytes (#84)
|
||||
* Fix `listen-https` port (no ticket)
|
||||
|
||||
## ntfy Android app v1.5.2
|
||||
Released Jan 3, 2022
|
||||
|
||||
**New features:**
|
||||
|
||||
* Allow using ntfy as UnifiedPush distributor (#9)
|
||||
* Support for longer message up to 4096 bytes (#77)
|
||||
* Minimum priority: show notifications only if priority X or higher (#79)
|
||||
* Allowing disabling broadcasts in global settings (#80)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Allow int/long extras for SEND_MESSAGE intent (#57)
|
||||
* Various battery improvement fixes (#76)
|
||||
|
||||
## ntfy server v1.11.2
|
||||
Released Jan 1, 2022
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* Increase message limit to 4096 bytes (4k) #77
|
||||
* Docs for [UnifiedPush](https://unifiedpush.org) #9
|
||||
* Increase keepalive interval to 55s #76
|
||||
* Increase Firebase keepalive to 3 hours #76
|
||||
|
||||
## ntfy server v1.10.0
|
||||
Released Dec 28, 2021
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* [Publish messages via e-mail](ntfy.sh/docs/publish/#e-mail-publishing) #66
|
||||
* Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64
|
||||
* Fixing the Santa bug #65
|
||||
|
||||
## Older releases
|
||||
For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
27
docs/static/css/extra.css
vendored
@ -1,16 +1,39 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
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;
|
||||
}
|
||||
|
||||
figure iframe, figure img, figure video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
figure img, figure video {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
|
||||
filter: drop-shadow(3px 3px 3px #1a1313);
|
||||
}
|
||||
|
||||
figure video {
|
||||
width: 100%;
|
||||
max-height: 450px;
|
||||
|
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 |
BIN
docs/static/img/web-detail.png
vendored
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 473 KiB |
BIN
docs/static/img/web-subscribe.png
vendored
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 76 KiB |
@ -1,9 +1,13 @@
|
||||
# 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
|
||||
You can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or
|
||||
**[via WebSockets](#websockets)**. Both are incredibly simple to use.
|
||||
|
||||
## HTTP stream
|
||||
The HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and
|
||||
the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which
|
||||
only differ in the response format:
|
||||
|
||||
@ -12,7 +16,7 @@ only differ in the response format:
|
||||
can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
||||
* [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message
|
||||
|
||||
## Subscribe as JSON stream
|
||||
### Subscribe as JSON stream
|
||||
Here are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the
|
||||
recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the
|
||||
[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with.
|
||||
@ -26,6 +30,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 +65,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');
|
||||
@ -65,7 +84,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
|
||||
fclose($fp);
|
||||
```
|
||||
|
||||
## Subscribe as SSE stream
|
||||
### Subscribe as SSE stream
|
||||
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
|
||||
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
|
||||
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
|
||||
@ -110,7 +129,7 @@ easy to use. Here's what it looks like. You may also want to check out the [live
|
||||
};
|
||||
```
|
||||
|
||||
## Subscribe as raw stream
|
||||
### Subscribe as raw stream
|
||||
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
|
||||
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
|
||||
[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output
|
||||
@ -150,6 +169,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,85 +188,54 @@ format. Keepalive messages are sent as empty lines.
|
||||
fclose($fp);
|
||||
```
|
||||
|
||||
## 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:
|
||||
## WebSockets
|
||||
You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely
|
||||
supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line,
|
||||
I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically
|
||||
for WebSockets.
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|---|---|---|---|---|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `event` | ✔️ | `open`, `keepalive` or `message` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the
|
||||
[JSON stream endpoint](#subscribe-as-json-stream).
|
||||
|
||||
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."
|
||||
}
|
||||
=== "Command line (websocat)"
|
||||
```
|
||||
$ websocat wss://ntfy.sh/mytopic/ws
|
||||
{"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"}
|
||||
{"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"}
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
GET /disk-alerts/ws HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
|
||||
=== "Notification message (minimal)"
|
||||
``` json
|
||||
{
|
||||
"id": "wze9zgqK41",
|
||||
"time": 1638542110,
|
||||
"event": "message",
|
||||
"topic": "phil_alerts",
|
||||
"message": "Remote access to phils-laptop detected. Act right away."
|
||||
}
|
||||
HTTP/1.1 101 Switching Protocols
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
...
|
||||
```
|
||||
|
||||
=== "Open message"
|
||||
``` json
|
||||
{
|
||||
"id": "2pgIAaGrQ8",
|
||||
"time": 1638542215,
|
||||
"event": "open",
|
||||
"topic": "phil_alerts"
|
||||
}
|
||||
=== "Go"
|
||||
``` go
|
||||
import "github.com/gorilla/websocket"
|
||||
ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil)
|
||||
messageType, data, err := ws.ReadMessage()
|
||||
...
|
||||
```
|
||||
|
||||
=== "Keepalive message"
|
||||
``` json
|
||||
{
|
||||
"id": "371sevb0pD",
|
||||
"time": 1638542275,
|
||||
"event": "keepalive",
|
||||
"topic": "phil_alerts"
|
||||
}
|
||||
```
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
const socket = new WebSocket('wss://ntfy.sh/mytopic/ws');
|
||||
socket.addEventListener('message', function (event) {
|
||||
console.log(event.data);
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced features
|
||||
|
||||
### 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
|
||||
### 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`).
|
||||
@ -248,9 +244,52 @@ combined with `since=` (defaults to `since=all`).
|
||||
curl -s "ntfy.sh/mytopic/json?poll=1"
|
||||
```
|
||||
|
||||
### Subscribing to multiple topics
|
||||
It's possible to subscribe to multiple topics in one HTTP call by providing a
|
||||
comma-separated list of topics in the URL. This allows you to reduce the number of connections you have to maintain:
|
||||
### Fetch cached messages
|
||||
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
||||
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
||||
the `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`),
|
||||
a message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages).
|
||||
|
||||
```
|
||||
curl -s "ntfy.sh/mytopic/json?since=10m"
|
||||
curl -s "ntfy.sh/mytopic/json?since=1645970742"
|
||||
curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe"
|
||||
```
|
||||
|
||||
### Fetch scheduled messages
|
||||
Messages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically
|
||||
returned when subscribing via the API, which makes sense, because after all, the messages have technically not been
|
||||
delivered yet. To also return scheduled messages from the API, you can use the `scheduled=1` (alias: `sched=1`)
|
||||
parameter (makes most sense with the `poll=1` parameter):
|
||||
|
||||
```
|
||||
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
||||
```
|
||||
|
||||
### Filter messages
|
||||
You can filter which messages are returned based on the well-known message fields `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
|
||||
@ -258,3 +297,126 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
|
||||
{"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"}
|
||||
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
||||
your password.
|
||||
|
||||
```
|
||||
curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json"
|
||||
```
|
||||
|
||||
## JSON message format
|
||||
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
||||
format of the message. It's very straight forward:
|
||||
|
||||
**Message**:
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
|
||||
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `name` | ✔️ | *string* | `attachment.jpg` | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) |
|
||||
| `url` | ✔️ | *URL* | `https://example.com/file.jpg` | URL of the attachment |
|
||||
| `type` | -️ | *mime type* | `image/jpeg` | Mime type of the attachment, only defined if attachment was uploaded to ntfy server |
|
||||
| `size` | -️ | *number* | `33848` | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server |
|
||||
| `expires` | -️ | *number* | `1635528741` | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server |
|
||||
|
||||
Here's an example for each message type:
|
||||
|
||||
=== "Notification message"
|
||||
``` json
|
||||
{
|
||||
"id": "sPs71M8A2T",
|
||||
"time": 1643935928,
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"priority": 5,
|
||||
"tags": [
|
||||
"warning",
|
||||
"skull"
|
||||
],
|
||||
"click": "https://homecam.mynet.lan/incident/1234",
|
||||
"attachment": {
|
||||
"name": "camera.jpg",
|
||||
"type": "image/png",
|
||||
"size": 33848,
|
||||
"expires": 1643946728,
|
||||
"url": "https://ntfy.sh/file/sPs71M8A2T.png"
|
||||
},
|
||||
"title": "Unauthorized access detected",
|
||||
"message": "Movement detected in the yard. You better go check"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
=== "Notification message (minimal)"
|
||||
``` json
|
||||
{
|
||||
"id": "wze9zgqK41",
|
||||
"time": 1638542110,
|
||||
"event": "message",
|
||||
"topic": "phil_alerts",
|
||||
"message": "Remote access to phils-laptop detected. Act right away."
|
||||
}
|
||||
```
|
||||
|
||||
=== "Open message"
|
||||
``` json
|
||||
{
|
||||
"id": "2pgIAaGrQ8",
|
||||
"time": 1638542215,
|
||||
"event": "open",
|
||||
"topic": "phil_alerts"
|
||||
}
|
||||
```
|
||||
|
||||
=== "Keepalive message"
|
||||
``` json
|
||||
{
|
||||
"id": "371sevb0pD",
|
||||
"time": 1638542275,
|
||||
"event": "keepalive",
|
||||
"topic": "phil_alerts"
|
||||
}
|
||||
```
|
||||
|
||||
=== "Poll request message"
|
||||
``` json
|
||||
{
|
||||
"id": "371sevb0pD",
|
||||
"time": 1638542275,
|
||||
"event": "poll_request",
|
||||
"topic": "phil_alerts"
|
||||
}
|
||||
```
|
||||
|
||||
## List of all parameters
|
||||
The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**,
|
||||
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
|
||||
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||
| `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) |
|
||||
|
222
docs/subscribe/cli.md
Normal file
@ -0,0 +1,222 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
|
||||
### Authentication
|
||||
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
||||
your password.
|
||||
|
||||
You can either add your username and password to the configuration file:
|
||||
=== "~/.config/ntfy/client.yml"
|
||||
```yaml
|
||||
- topic: secret
|
||||
command: 'notify-send "$m"'
|
||||
user: phill
|
||||
password: mypass
|
||||
```
|
||||
|
||||
Or with the `ntfy subscibe` command:
|
||||
```
|
||||
ntfy subscribe \
|
||||
-u phil:mypass \
|
||||
ntfy.example.com/mysecrets
|
||||
```
|
@ -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,101 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
|
||||
## Integrations
|
||||
|
||||
### UnifiedPush
|
||||
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||
|
||||
To use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/).
|
||||
That's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md)
|
||||
to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
||||
|
||||
<div id="unifiedpush-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"><img src="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-unifiedpush-subscription.jpg"><img src="../../static/img/android-screenshot-unifiedpush-subscription.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-unifiedpush-settings.jpg"><img src="../../static/img/android-screenshot-unifiedpush-settings.jpg"/></a>
|
||||
</div>
|
||||
|
||||
### Automation apps
|
||||
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
||||
**react to incoming messages**, as well as **send messages**.
|
||||
|
||||
#### React to incoming messages
|
||||
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
|
||||
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
|
||||
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
|
||||
broadcasts is supported:
|
||||
|
||||
<div id="integration-screenshots-receive" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-action-edit.png"><img src="../../static/img/android-screenshot-tasker-action-edit.png"/></a>
|
||||
</div>
|
||||
|
||||
For MacroDroid, be sure to type in the package name `io.heckel.ntfy`, otherwise intents may be silently swallowed.
|
||||
If you're using topics to drive automation, you'll likely want to mute the topic in the ntfy app. This will prevent
|
||||
notification popups:
|
||||
|
||||
<figure markdown>
|
||||
![muted subscription](../static/img/android-screenshot-muted.png){ width=500 }
|
||||
<figcaption>Muting notifications to prevent popups</figcaption>
|
||||
</figure>
|
||||
|
||||
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
||||
|
||||
| Extra name | Type | Example | Description |
|
||||
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
|
||||
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
#### 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.
|
||||
|
@ -6,9 +6,9 @@ keep a connection open and listen for incoming notifications.
|
||||
To learn how to send messages, check out the [publishing page](../publish.md).
|
||||
|
||||
<div id="web-screenshots" class="screenshots">
|
||||
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
|
||||
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
|
||||
<a href="../../static/img/web-detail.png"><img src="../../static/img/web-detail.png"/></a>
|
||||
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
|
||||
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
|
||||
</div>
|
||||
|
||||
To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
|
||||
|
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)
|
53
go.mod
@ -4,39 +4,48 @@ go 1.17
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||
cloud.google.com/go/storage v1.18.2 // indirect
|
||||
cloud.google.com/go/storage v1.21.0 // indirect
|
||||
firebase.google.com/go v3.13.0+incompatible
|
||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||
github.com/BurntSushi/toml v1.0.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.9
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 // indirect
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
|
||||
google.golang.org/api v0.60.0
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/mattn/go-sqlite3 v1.14.12
|
||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/urfave/cli/v2 v2.4.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
|
||||
google.golang.org/api v0.73.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.97.0 // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect
|
||||
github.com/envoyproxy/go-control-plane v0.10.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
||||
cloud.google.com/go v0.100.2 // indirect
|
||||
cloud.google.com/go/compute v1.5.0 // indirect
|
||||
cloud.google.com/go/iam v0.3.0 // indirect
|
||||
github.com/AlekSi/pointer v1.0.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
|
||||
github.com/google/go-cmp v0.5.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 // indirect
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // 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-20211101144312-62acf1d99145 // indirect
|
||||
google.golang.org/grpc v1.41.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect
|
||||
google.golang.org/grpc v1.45.0 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
)
|
||||
|
172
go.sum
@ -24,20 +24,29 @@ cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWc
|
||||
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
|
||||
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 h1:3DXvAyifywvq64LfkKaMOmkWPS1CikIQdMe2lY9vxU8=
|
||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
|
||||
cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
|
||||
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
|
||||
cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
|
||||
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
|
||||
cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM=
|
||||
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.6.0 h1:dMIWvm+3O0E3DM7kcZPH0FBQ94Xg/OMkdTNDaY9itbI=
|
||||
cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU=
|
||||
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
|
||||
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
|
||||
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
@ -47,63 +56,55 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY=
|
||||
cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM=
|
||||
cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14=
|
||||
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
|
||||
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
|
||||
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
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/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
|
||||
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=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed h1:OZmjad4L3H8ncOIR8rnb5MREYqG8ixi5+WbeUsquF0c=
|
||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/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=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 h1:dulLQAYQFYtG5MTplgNGHWuV2D+OBD+Z8lmDBmbLg+s=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||
github.com/envoyproxy/go-control-plane v0.10.0 h1:WVt4HEPbdRbRD/PKKPbPnIVavO6gk/h673jWyIJ016k=
|
||||
github.com/envoyproxy/go-control-plane v0.10.0/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
|
||||
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=
|
||||
@ -111,7 +112,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@ -156,8 +156,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@ -179,53 +180,52 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||
github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
|
||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
||||
github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
|
||||
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
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.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
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/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/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/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I=
|
||||
github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@ -243,8 +243,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@ -281,7 +281,6 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@ -316,10 +315,11 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk=
|
||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/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/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@ -336,8 +336,10 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM=
|
||||
golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -348,6 +350,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -389,17 +392,25 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 h1:2B5p2L5IfGiD7+b9BOoRMC6DgObAVZV+Fsp050NqXik=
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/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-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
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=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -407,15 +418,14 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@ -501,10 +511,17 @@ google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6
|
||||
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
|
||||
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
|
||||
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
||||
google.golang.org/api v0.60.0 h1:eq/zs5WPH4J9undYM9IP1O7dSr7Yh8Y0GtSCpzGzIUk=
|
||||
google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4=
|
||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
||||
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
||||
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
|
||||
google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
|
||||
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
|
||||
google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
|
||||
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
|
||||
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
|
||||
google.golang.org/api v0.73.0 h1:O9bThUh35K1rvUrQwTUQ1eqLC/IYyzUpWavYIO2EXvo=
|
||||
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
|
||||
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=
|
||||
@ -568,15 +585,27 @@ google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEc
|
||||
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c h1:FqrtZMB5Wr+/RecOM3uPJNPfWR8Upb5hAPnt7PU6i4k=
|
||||
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145 h1:vum3nDKdleYb+aePXKFEDT2+ghuH00EgYp9B7Q7EZZE=
|
||||
google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE=
|
||||
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@ -601,10 +630,11 @@ google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
|
||||
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||
google.golang.org/grpc v1.40.0 h1:AGJ0Ih4mHjSeibYkFGh1dD9KJ/eOtZ93I6hoHhukQ5Q=
|
||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||
google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E=
|
||||
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
|
||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
||||
google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
|
||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||
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=
|
||||
@ -626,8 +656,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
8
main.go
@ -16,10 +16,14 @@ 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), or
|
||||
the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
||||
|
||||
ntfy %s (%s), runtime %s, built at %s
|
||||
Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0
|
||||
Copyright (C) 2022 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||
`, version, commit[:7], runtime.Version(), date)
|
||||
|
||||
app := cmd.New()
|
||||
|
14
mkdocs.yml
@ -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
|
||||
@ -17,23 +17,20 @@ theme:
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)" # Light mode
|
||||
scheme: default
|
||||
primary: teal
|
||||
toggle:
|
||||
icon: material/lightbulb-outline
|
||||
name: Switch to light mode
|
||||
name: Switch to dark mode
|
||||
- media: "(prefers-color-scheme: dark)" # Dark mode
|
||||
scheme: slate
|
||||
primary: teal
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/lightbulb
|
||||
name: Switch to dark mode
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- search.share
|
||||
- navigation.sections
|
||||
# - navigation.instant
|
||||
- toc.integrate
|
||||
- content.tabs.link
|
||||
extra:
|
||||
@ -77,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
|
||||
@ -84,6 +82,8 @@ nav:
|
||||
- "Other things":
|
||||
- "FAQs": faq.md
|
||||
- "Examples": examples.md
|
||||
- "Release notes": releases.md
|
||||
- "Deprecation notices": deprecations.md
|
||||
- "Emojis 🥳 🎉": emojis.md
|
||||
- "Development": develop.md
|
||||
- "Privacy policy": privacy.md
|
||||
|
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
# The documentation uses 'mkdocs', which is written in Python
|
||||
|
||||
# See https://github.com/squidfunk/mkdocs-material/issues/2030
|
||||
jinja2>=2.11.1
|
||||
|
||||
# mkdocs
|
||||
mkdocs
|
||||
mkdocs-material
|
||||
mkdocs-minify-plugin
|
@ -18,7 +18,7 @@ fi
|
||||
if [[ "$1" == *.js ]]; then
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
const rawEmojis = " > "$1"
|
||||
export const rawEmojis = " > "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
|
||||
elif [[ "$1" == *.md ]]; then
|
||||
echo "# Emoji reference
|
||||
|
@ -4,16 +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
|
||||
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
|
||||
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 /var/lib/ntfy
|
||||
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
|
||||
|
||||
# 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
|
||||
|
||||
# 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,7 +2,9 @@
|
||||
set -e
|
||||
|
||||
# Delete the config if package is purged
|
||||
if [ "$1" = "purge" ]; then
|
||||
echo "Deleting /etc/ntfy ..."
|
||||
rm -rf /etc/ntfy || true
|
||||
if [ "$1" = "purge" ] || [ "$1" = "0" ]; then
|
||||
id ntfy >/dev/null 2>&1 && userdel ntfy
|
||||
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
|
||||
|
@ -1,14 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"time"
|
||||
)
|
||||
|
||||
type cache interface {
|
||||
AddMessage(m *message) error
|
||||
Messages(topic string, since sinceTime) ([]*message, error)
|
||||
MessageCount(topic string) (int, error)
|
||||
Topics() (map[string]*topic, error)
|
||||
Prune(keep time.Duration) error
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memCache struct {
|
||||
messages map[string][]*message
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ cache = (*memCache)(nil)
|
||||
|
||||
func newMemCache() *memCache {
|
||||
return &memCache{
|
||||
messages: make(map[string][]*message),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *memCache) AddMessage(m *message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.messages[m.Topic]; !ok {
|
||||
s.messages[m.Topic] = make([]*message, 0)
|
||||
}
|
||||
s.messages[m.Topic] = append(s.messages[m.Topic], m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memCache) Messages(topic string, since sinceTime) ([]*message, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.messages[topic]; !ok {
|
||||
return make([]*message, 0), nil
|
||||
}
|
||||
messages := make([]*message, 0) // copy!
|
||||
for _, m := range s.messages[topic] {
|
||||
msgTime := time.Unix(m.Time, 0)
|
||||
if msgTime == since.Time() || msgTime.After(since.Time()) {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (s *memCache) MessageCount(topic string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.messages[topic]; !ok {
|
||||
return 0, nil
|
||||
}
|
||||
return len(s.messages[topic]), nil
|
||||
}
|
||||
|
||||
func (s *memCache) Topics() (map[string]*topic, error) {
|
||||
// Hack since we know when this is called there are no messages!
|
||||
return make(map[string]*topic), nil
|
||||
}
|
||||
|
||||
func (s *memCache) Prune(keep time.Duration) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for topic, _ := range s.messages {
|
||||
s.pruneTopic(topic, keep)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memCache) pruneTopic(topic string, keep time.Duration) {
|
||||
for i, m := range s.messages[topic] {
|
||||
msgTime := time.Unix(m.Time, 0)
|
||||
if time.Since(msgTime) < keep {
|
||||
s.messages[topic] = s.messages[topic][i:]
|
||||
return
|
||||
}
|
||||
}
|
||||
s.messages[topic] = make([]*message, 0) // all messages expired
|
||||
}
|
@ -1,225 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Messages cache
|
||||
const (
|
||||
createMessagesTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(512) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags VARCHAR(256) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
COMMIT;
|
||||
`
|
||||
insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ?`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT id, time, message, title, priority, tags
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time ASC
|
||||
`
|
||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||
selectTopicsQuery = `SELECT topic, MAX(time) FROM messages GROUP BY topic`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 1
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
`
|
||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
|
||||
// 0 -> 1
|
||||
migrate0To1AlterMessagesTableQuery = `
|
||||
BEGIN;
|
||||
ALTER TABLE messages ADD COLUMN title VARCHAR(256) NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
||||
ALTER TABLE messages ADD COLUMN tags VARCHAR(256) NOT NULL DEFAULT('');
|
||||
COMMIT;
|
||||
`
|
||||
)
|
||||
|
||||
type sqliteCache struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
var _ cache = (*sqliteCache)(nil)
|
||||
|
||||
func newSqliteCache(filename string) (*sqliteCache, error) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupDB(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sqliteCache{
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) AddMessage(m *message) error {
|
||||
_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error) {
|
||||
rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
messages := make([]*message, 0)
|
||||
for rows.Next() {
|
||||
var timestamp int64
|
||||
var priority int
|
||||
var id, msg, title, tagsStr string
|
||||
if err := rows.Scan(&id, ×tamp, &msg, &title, &priority, &tagsStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == "" {
|
||||
msg = " " // Hack: never return empty messages; this should not happen
|
||||
}
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
}
|
||||
messages = append(messages, &message{
|
||||
ID: id,
|
||||
Time: timestamp,
|
||||
Event: messageEvent,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) MessageCount(topic string) (int, error) {
|
||||
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var count int
|
||||
if !rows.Next() {
|
||||
return 0, errors.New("no rows found")
|
||||
}
|
||||
if err := rows.Scan(&count); err != nil {
|
||||
return 0, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *sqliteCache) Topics() (map[string]*topic, error) {
|
||||
rows, err := s.db.Query(selectTopicsQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
topics := make(map[string]*topic, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var last int64
|
||||
if err := rows.Scan(&id, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topics[id] = newTopic(id, time.Unix(last, 0))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
func (s *sqliteCache) Prune(keep time.Duration) error {
|
||||
_, err := s.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func setupDB(db *sql.DB) error {
|
||||
// If 'messages' table does not exist, this must be a new database
|
||||
rowsMC, err := db.Query(selectMessagesCountQuery)
|
||||
if err != nil {
|
||||
return setupNewDB(db)
|
||||
}
|
||||
defer rowsMC.Close()
|
||||
|
||||
// If 'messages' table exists, check 'schemaVersion' table
|
||||
schemaVersion := 0
|
||||
rowsSV, err := db.Query(selectSchemaVersionQuery)
|
||||
if err == nil {
|
||||
defer rowsSV.Close()
|
||||
if !rowsSV.Next() {
|
||||
return errors.New("cannot determine schema version: cache file may be corrupt")
|
||||
}
|
||||
if err := rowsSV.Scan(&schemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Do migrations
|
||||
if schemaVersion == currentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion == 0 {
|
||||
return migrateFrom0To1(db)
|
||||
}
|
||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||
}
|
||||
|
||||
func setupNewDB(db *sql.DB) error {
|
||||
if _, err := db.Exec(createMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateFrom0To1(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 0 to 1")
|
||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|