Compare commits

...

20 Commits

Author SHA1 Message Date
Philipp Heckel
b9b53bcdf0 Fix Chrome/Firefox inconsistencies with sorting 2021-11-08 10:35:46 -05:00
Philipp Heckel
a1385f6785 Update readme 2021-11-08 09:48:55 -05:00
Philipp Heckel
d453db89a7 Add since=all; make poll=1 default to since=all 2021-11-08 09:46:31 -05:00
Philipp Heckel
43c9a92748 Detail page in web UI 2021-11-08 09:24:34 -05:00
Philipp Heckel
c01c94c64c Fix content type to add charset 2021-11-07 13:08:03 -05:00
Philipp Heckel
7adb0e4f2f Bump 2021-11-05 13:47:36 -04:00
Philipp Heckel
0170f673bd Fix rate limiting behind proxy, make configurable 2021-11-05 13:46:27 -04:00
Philipp Heckel
86a16e3944 More tweaking 2021-11-04 22:47:29 -04:00
Philipp Heckel
c9124cb5eb Make web ui prettier 2021-11-04 22:32:17 -04:00
Philipp Heckel
644ffa1420 WIP: Web UI improvements 2021-11-04 10:55:34 -04:00
Philipp Heckel
5948f39a53 Add firebase subscriber to topics from cache upon initialization; stricter rate limits 2021-11-03 21:16:07 -04:00
Philipp Heckel
eef85c0955 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-03 11:46:45 -04:00
Philipp Heckel
60cbf23bcc Fix CGO stuff for sqlite 2021-11-03 11:46:24 -04:00
Philipp C. Heckel
3b5235ed01 Update README.md 2021-11-03 11:38:46 -04:00
Philipp C. Heckel
54366d105f Update README.md 2021-11-03 11:36:09 -04:00
Philipp C. Heckel
5356580fc6 Update README.md 2021-11-03 11:35:53 -04:00
Philipp C. Heckel
6ccadb09dd Update README.md 2021-11-03 11:35:39 -04:00
Philipp Heckel
ecde123f1c readme 2021-11-03 11:34:12 -04:00
Philipp Heckel
56ffa551f3 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-03 11:33:47 -04:00
Philipp Heckel
30a1ffa7cf Clean up readme 2021-11-03 11:33:34 -04:00
18 changed files with 806 additions and 184 deletions

View File

@@ -4,11 +4,9 @@ before:
builds: builds:
- binary: ntfy - binary: ntfy
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=1 # required for go-sqlite3
goos: goos:
- linux - linux
- windows
- darwin
goarch: goarch:
- amd64 - amd64
nfpms: nfpms:

View File

@@ -94,6 +94,7 @@ build-snapshot:
build-simple: clean build-simple: clean
mkdir -p dist/ntfy_linux_amd64 mkdir -p dist/ntfy_linux_amd64
export CGO_ENABLED=1
$(GO) build \ $(GO) build \
-o dist/ntfy_linux_amd64/ntfy \ -o dist/ntfy_linux_amd64/ntfy \
-ldflags \ -ldflags \

146
README.md
View File

@@ -1,47 +1,112 @@
# ntfy ![ntfy](server/static/img/ntfy.png)
ntfy (pronounce: *notify*) is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications # ntfy - simple HTTP-based pub-sub
via scripts. I run a free version of it on *[ntfy.sh](https://ntfy.sh)*. **No signups or cost.**
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
It's also open source (as you can plainly see) if you want to run your own.
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
too.
## Usage ## Usage
### Subscribe to a topic ### Publishing messages
Topics are created on the fly by subscribing to them. You can create and subscribe to a topic either in a web UI, or in
your own app by subscribing to an [SSE](https://en.wikipedia.org/wiki/Server-sent_events)/[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource),
or a JSON or raw feed.
Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Publishing messages can be done via 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 how you can create a topic `mytopic`, subscribe to it topic and wait for events. This is using `curl`, but you Here's an example showing how to publish a message using `curl`:
can use any library that can do HTTP GETs:
```
# Subscribe to "mytopic" and output one message per line (\n are replaced with a space)
curl -s ntfy.sh/mytopic/raw
# Subscribe to "mytopic" and output one JSON message per line
curl -s ntfy.sh/mytopic/json
# Subscribe to "mytopic" and output an SSE stream (supported via JS/EventSource)
curl -s ntfy.sh/mytopic/sse
```
You can easily script it to execute any command when a message arrives. This sends desktop notifications (just like
the web UI, but without it):
```
while read msg; do
[ -n "$msg" ] && notify-send "$msg"
done < <(stdbuf -i0 -o0 curl -s ntfy.sh/mytopic/raw)
```
### Publish messages
Publishing messages can be done via PUT or POST using. Here's an example using `curl`:
``` ```
curl -d "long process is done" ntfy.sh/mytopic curl -d "long process is done" ntfy.sh/mytopic
``` ```
Messages published to a non-existing topic or a topic without subscribers will not be delivered later. There is (currently) Here's an example in JS with `fetch()` (see [full example](examples)):
no buffering of any kind. If you're not listening, the message won't be delivered.
```
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 ## Installation
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
@@ -64,13 +129,13 @@ sudo apt install ntfy
**Debian/Ubuntu** (*manual install*)**:** **Debian/Ubuntu** (*manual install*)**:**
```bash ```bash
sudo apt install tmux sudo apt install tmux
wget https://github.com/binwiederhier/ntfy/releases/download/v1.1.2/ntfy_1.1.2_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v1.3.0/ntfy_1.3.0_amd64.deb
dpkg -i ntfy_1.1.2_amd64.deb dpkg -i ntfy_1.3.0_amd64.deb
``` ```
**Fedora/RHEL/CentOS:** **Fedora/RHEL/CentOS:**
```bash ```bash
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.1.2/ntfy_1.1.2_amd64.rpm rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.3.0/ntfy_1.3.0_amd64.rpm
``` ```
**Docker:** **Docker:**
@@ -85,8 +150,8 @@ go get -u heckel.io/ntfy
**Manual install** (*any x86_64-based Linux*)**:** **Manual install** (*any x86_64-based Linux*)**:**
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.1.2/ntfy_1.1.2_linux_x86_64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v1.3.0/ntfy_1.3.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_1.1.2_linux_x86_64.tar.gz ntfy sudo tar -C /usr/bin -zxf ntfy_1.3.0_linux_x86_64.tar.gz ntfy
./ntfy ./ntfy
``` ```
@@ -104,7 +169,6 @@ To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that
## TODO ## TODO
- add HTTPS - add HTTPS
- make limits configurable - make limits configurable
- limit max number of subscriptions
## Contributing ## Contributing
I welcome any and all contributions. Just create a PR or an issue. I welcome any and all contributions. Just create a PR or an issue.
@@ -116,4 +180,6 @@ Third party libraries and resources:
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI * [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 * [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 * [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases * [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages

View File

@@ -19,11 +19,16 @@ func New() *cli.App {
flags := []cli.Flag{ 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"}, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/config.yml", DefaultText: "/etc/ntfy/config.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: config.DefaultListenHTTP, Usage: "ip:port used to as listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: config.DefaultListenHTTP, Usage: "ip:port used to as listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
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: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "message-buffer-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_MESSAGE_BUFFER_DURATION"}, Value: config.DefaultMessageBufferDuration, Usage: "buffer messages in memory for this time to allow `since` requests"}), 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: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "default interval of keepalive messages"}), 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: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: config.DefaultManagerInterval, Usage: "default interval of for message pruning and stats printing"}), 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{ return &cli.App{
Name: "ntfy", Name: "ntfy",
@@ -45,11 +50,16 @@ func New() *cli.App {
func execRun(c *cli.Context) error { func execRun(c *cli.Context) error {
// Read all the options // Read all the options
listenHTTP := c.String("listen-http") listenHTTP := c.String("listen-http")
cacheFile := c.String("cache-file")
firebaseKeyFile := c.String("firebase-key-file") firebaseKeyFile := c.String("firebase-key-file")
messageBufferDuration := c.Duration("message-buffer-duration") cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
keepaliveInterval := c.Duration("keepalive-interval") keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-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 // Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
@@ -58,17 +68,22 @@ func execRun(c *cli.Context) error {
return errors.New("keepalive interval cannot be lower than five seconds") return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second { } else if managerInterval < 5*time.Second {
return errors.New("manager interval cannot be lower than five seconds") return errors.New("manager interval cannot be lower than five seconds")
} else if messageBufferDuration < managerInterval { } else if cacheDuration < managerInterval {
return errors.New("message buffer duration cannot be lower than manager interval") return errors.New("cache duration cannot be lower than manager interval")
} }
// Run server // Run server
conf := config.New(listenHTTP) conf := config.New(listenHTTP)
conf.CacheFile = cacheFile
conf.FirebaseKeyFile = firebaseKeyFile conf.FirebaseKeyFile = firebaseKeyFile
conf.MessageBufferDuration = messageBufferDuration conf.CacheFile = cacheFile
conf.CacheDuration = cacheDuration
conf.KeepaliveInterval = keepaliveInterval conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval conf.ManagerInterval = managerInterval
conf.GlobalTopicLimit = globalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.BehindProxy = behindProxy
s, err := server.New(conf) s, err := server.New(conf)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)

View File

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

View File

@@ -10,16 +10,45 @@
# #
# firebase-key-file: <filename> # 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. # Duration for which messages will be buffered before they are deleted.
# This is required to support the "since=..." and "poll=1" parameter. # This is required to support the "since=..." and "poll=1" parameter.
# #
# message-buffer-duration: 12h # cache-duration: 12h
# Interval in which keepalive messages are sent to the client. This is to prevent # Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity. # intermediaries closing the connection for inactivity.
# #
# keepalive-interval: 30s # keepalive-interval: 30s
# Interval in which the manager prunes old messages, deletes topics and prints the stats. # Interval in which the manager prunes old messages, deletes topics
# and prints the stats.
# #
# manager-interval: 1m # 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

View File

@@ -7,7 +7,7 @@ import (
type cache interface { type cache interface {
AddMessage(m *message) error AddMessage(m *message) error
Messages(topic string, since time.Time) ([]*message, error) Messages(topic string, since sinceTime) ([]*message, error)
MessageCount(topic string) (int, error) MessageCount(topic string) (int, error)
Topics() (map[string]*topic, error) Topics() (map[string]*topic, error)
Prune(keep time.Duration) error Prune(keep time.Duration) error

View File

@@ -7,8 +7,8 @@ import (
) )
type memCache struct { type memCache struct {
messages map[string][]*message messages map[string][]*message
mu sync.Mutex mu sync.Mutex
} }
var _ cache = (*memCache)(nil) var _ cache = (*memCache)(nil)
@@ -29,7 +29,7 @@ func (s *memCache) AddMessage(m *message) error {
return nil return nil
} }
func (s *memCache) Messages(topic string, since time.Time) ([]*message, error) { func (s *memCache) Messages(topic string, since sinceTime) ([]*message, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if _, ok := s.messages[topic]; !ok { if _, ok := s.messages[topic]; !ok {
@@ -38,7 +38,7 @@ func (s *memCache) Messages(topic string, since time.Time) ([]*message, error) {
messages := make([]*message, 0) // copy! messages := make([]*message, 0) // copy!
for _, m := range s.messages[topic] { for _, m := range s.messages[topic] {
msgTime := time.Unix(m.Time, 0) msgTime := time.Unix(m.Time, 0)
if msgTime == since || msgTime.After(since) { if msgTime == since.Time() || msgTime.After(since.Time()) {
messages = append(messages, m) messages = append(messages, m)
} }
} }

View File

@@ -19,8 +19,8 @@ const (
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT; COMMIT;
` `
insertMessageQuery = `INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)` insertMessageQuery = `INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ?` pruneMessagesQuery = `DELETE FROM messages WHERE time < ?`
selectMessagesSinceTimeQuery = ` selectMessagesSinceTimeQuery = `
SELECT id, time, message SELECT id, time, message
FROM messages FROM messages
@@ -46,7 +46,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
return nil, err return nil, err
} }
return &sqliteCache{ return &sqliteCache{
db: db, db: db,
}, nil }, nil
} }
@@ -55,8 +55,8 @@ func (c *sqliteCache) AddMessage(m *message) error {
return err return err
} }
func (c *sqliteCache) Messages(topic string, since time.Time) ([]*message, error) { func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error) {
rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Unix()) rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -122,6 +122,6 @@ func (s *sqliteCache) Topics() (map[string]*topic, error) {
} }
func (c *sqliteCache) Prune(keep time.Duration) error { func (c *sqliteCache) Prune(keep time.Duration) error {
_, err := c.db.Exec(pruneMessagesQuery, time.Now().Add(-1 * keep).Unix()) _, err := c.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix())
return err return err
} }

View File

@@ -1,3 +1,4 @@
{{- /*gotype: heckel.io/ntfy/server.indexPage*/ -}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -27,16 +28,24 @@
<meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." /> <meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
<meta property="og:image" content="/static/img/ntfy.png" /> <meta property="og:image" content="/static/img/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" /> <meta property="og:url" content="https://ntfy.sh" />
{{if .Topic}}
<!-- Never index topic page -->
<meta name="robots" content="noindex, nofollow" />
{{end}}
</head> </head>
<body> <body>
<div id="main"> <div id="main"{{if .Topic}} style="display: none"{{end}}>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh - simple HTTP-based pub-sub</h1> <h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh | simple HTTP-based pub-sub</h1>
<p> <p>
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service. <b>Ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service.
It allows you to send <b>desktop notifications via scripts from any computer</b>, entirely <b>without signup or cost</b>. It allows you to send notifications <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">to your phone</a> or desktop via scripts from any computer,
It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own. entirely <b>without signup or cost</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
</p>
<p>
There are many ways to use ntfy. You can send yourself messages for all sorts of things: When a long process finishes or fails (a backup, a long rsync job, ...),
or to notify yourself when somebody logs into your server(s). Or you may want to use it in your own app to distribute messages to subscribed clients.
Endless possibilities 😀.
</p> </p>
<p id="error"></p>
<h2>Publishing messages</h2> <h2>Publishing messages</h2>
<p> <p>
@@ -65,26 +74,29 @@
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>, a JSON feed, or raw feed. <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>, a JSON feed, or raw feed.
</p> </p>
<h3>Subscribe via web</h3> <div id="subscribeBox">
<p> <h3>Subscribe in this Web UI</h3>
If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic <p id="error"></p>
will show up as <b>desktop notification</b>.
</p>
<form id="subscribeForm">
<p> <p>
<label for="topicField">Subscribe to topic:</label> Subscribe to topics here and receive messages as <b>desktop notification</b>. Topics are not password-protected,
<input type="text" id="topicField" placeholder="Letters, numbers, _ and -" pattern="[-_A-Za-z]{1,64}" /> so choose a name that's not easy to guess. Once subscribed, you can publish messages via PUT/POST.
<input type="submit" id="subscribeButton" value="Subscribe" />
</p> </p>
</form> <form id="subscribeForm">
<p id="topicsHeader">Topics:</p> <p>
<ul id="topicsList"></ul> <b>Topic:</b><br/>
<audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio> <input type="text" id="topicField" autocomplete="off" placeholder="Topic name, e.g. phil_alerts" maxlength="64" pattern="[-_A-Za-z0-9]{1,64}" />
<button id="subscribeButton">Subscribe</button>
</p>
<p id="topicsHeader"><b>Subscribed topics:</b></p>
<ul id="topicsList"></ul>
</form>
<audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
</div>
<h3>Subscribe via phone</h3> <h3>Subscribe via Android App</h3>
<p> <p>
Once it's approved, you can use the <b>Ntfy Android App</b> to receive notifications directly on your phone. Just like You can use the <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">Ntfy Android App</a>
the server, this app is also <a href="https://github.com/binwiederhier/ntfy-android">open source</a>. to receive notifications directly on your phone. Just like the server, this app is also <a href="https://github.com/binwiederhier/ntfy-android">open source</a>.
</p> </p>
<h3>Subscribe via your app, or via the CLI</h3> <h3>Subscribe via your app, or via the CLI</h3>
@@ -128,6 +140,14 @@
<br/> <br/>
This is a notification This is a notification
</code> </code>
<p class="smallMarginBottom">
Here's an example of how to use this endpoint to send desktop notifications for every incoming message:
</p>
<code>
while read msg; do<br/>
&nbsp;&nbsp;[ -n "$msg" ] && notify-send "$msg"<br/>
done < <(stdbuf -i0 -o0 curl -s ntfy.sh/mytopic/raw)
</code>
<h3>Message buffering and polling</h3> <h3>Message buffering and polling</h3>
<p class="smallMarginBottom"> <p class="smallMarginBottom">
@@ -177,18 +197,16 @@
In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also
published to Firebase Cloud Messaging (FCM) (if <tt>FirebaseKeyFile</tt> is set, which it is on ntfy.sh). This published to Firebase Cloud Messaging (FCM) (if <tt>FirebaseKeyFile</tt> is set, which it is on ntfy.sh). This
is to facilitate instant notifications on Android. I tried really, really hard to avoid using FCM, but newer is to facilitate instant notifications on Android. I tried really, really hard to avoid using FCM, but newer
versions of Android made it impossible to implement <a href="https://developer.android.com/guide/background">background services</a>>. versions of Android made it impossible to implement <a href="https://developer.android.com/guide/background">background services</a>.
I'm sorry.
</p> </p>
<h2>Privacy policy</h2> <h2>Privacy policy</h2>
<p> <p>
Neither the server nor the app record any personal information, or share any of the messages and topics with Neither the server nor the app record any personal information, or share any of the messages and topics with
any outside service. All data is exclusively used to make the service function properly. The notable exception any outside service. All data is exclusively used to make the service function properly. The one exception
is the Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see is the Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
FAQ for details). FAQ for details).
</p> </p>
<p> <p>
The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages, The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
aside from a short on-disk cache (up to a day) to support service restarts. aside from a short on-disk cache (up to a day) to support service restarts.
@@ -196,6 +214,31 @@
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center> <center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
</div> </div>
<div id="detail"{{if not .Topic}} style="display: none"{{end}}>
<div id="detailMain">
<button id="detailCloseButton"><img src="static/img/close_black_24dp.svg"/></button>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/><span id="detailTitle"></span></h1>
<p class="smallMarginBottom">
<b>Ntfy</b> is a simple HTTP-based pub-sub notification service. This is a Ntfy topic.
To send notifications to it, simply PUT or POST to the topic URL. Here's an example using <tt>curl</tt>:
</p>
<code>
curl -d "Backup failed" <span id="detailTopicUrl"></span>
</code>
<p id="detailNotificationsDisallowed">
If you'd like to receive desktop notifications when new messages arrive on this topic, you have
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
Click the link to do so.
</p>
<p class="smallMarginBottom">
<b>Recent notifications</b> (cached for {{.CacheDuration}}):
</p>
<p id="detailNoNotifications">
<i>You haven't received any notifications for this topic yet.</i>
</p>
<div id="detailEventsList"></div>
</div>
</div>
<script src="static/js/app.js"></script> <script src="static/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -11,6 +11,8 @@ import (
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"
"heckel.io/ntfy/config" "heckel.io/ntfy/config"
"heckel.io/ntfy/util"
"html/template"
"io" "io"
"log" "log"
"net" "net"
@@ -46,20 +48,45 @@ func (e errHTTP) Error() string {
return fmt.Sprintf("http: %s", e.Status) return fmt.Sprintf("http: %s", e.Status)
} }
type indexPage struct {
Topic string
CacheDuration string
}
type sinceTime time.Time
func (t sinceTime) IsAll() bool {
return t == sinceAllMessages
}
func (t sinceTime) IsNone() bool {
return t == sinceNoMessages
}
func (t sinceTime) Time() time.Time {
return time.Time(t)
}
var (
sinceAllMessages = sinceTime(time.Unix(0, 0))
sinceNoMessages = sinceTime(time.Unix(1, 0))
)
const ( const (
messageLimit = 1024 messageLimit = 512
) )
var ( var (
topicRegex = regexp.MustCompile(`^/[^/]+$`) topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`) jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/json$`)
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`) sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/sse$`)
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`) rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/raw$`)
staticRegex = regexp.MustCompile(`^/static/.+`) staticRegex = regexp.MustCompile(`^/static/.+`)
//go:embed "index.html" //go:embed "index.gohtml"
indexSource string indexSource string
indexTemplate = template.Must(template.New("index").Parse(indexSource))
//go:embed static //go:embed static
webStaticFs embed.FS webStaticFs embed.FS
@@ -86,6 +113,11 @@ func New(conf *config.Config) (*Server, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, t := range topics {
if firebaseSubscriber != nil {
t.Subscribe(firebaseSubscriber)
}
}
return &Server{ return &Server{
config: conf, config: conf,
cache: cache, cache: cache,
@@ -154,31 +186,35 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
v := s.visitor(r.RemoteAddr) if r.Method == http.MethodGet && (r.URL.Path == "/" || topicRegex.MatchString(r.URL.Path)) {
if err := v.RequestAllowed(); err != nil {
return err
}
if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.handleHome(w, r) return s.handleHome(w, r)
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.handleEmpty(w, r)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r) return s.handleStatic(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
return s.handlePublish(w, r, v)
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
return s.handleSubscribeJSON(w, r, v)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
return s.handleSubscribeSSE(w, r, v)
} else if r.Method == http.MethodGet && rawRegex.MatchString(r.URL.Path) {
return s.handleSubscribeRaw(w, r, v)
} else if r.Method == http.MethodOptions { } else if r.Method == http.MethodOptions {
return s.handleOptions(w, r) return s.handleOptions(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeJSON)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeSSE)
} else if r.Method == http.MethodGet && rawRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeRaw)
} }
return errHTTPNotFound return errHTTPNotFound
} }
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
_, err := io.WriteString(w, indexSource) return indexTemplate.Execute(w, &indexPage{
return err Topic: r.URL.Path[1:],
CacheDuration: util.DurationToHuman(s.config.CacheDuration),
})
}
func (s *Server) handleEmpty(w http.ResponseWriter, r *http.Request) error {
return nil
} }
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
@@ -204,6 +240,9 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return err return err
} }
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(m); err != nil {
return err
}
s.mu.Lock() s.mu.Lock()
s.messages++ s.messages++
s.mu.Unlock() s.mu.Unlock()
@@ -218,7 +257,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
} }
return buf.String(), nil return buf.String(), nil
} }
return s.handleSubscribe(w, r, v, "json", "application/stream+json", encoder) return s.handleSubscribe(w, r, v, "json", "application/x-ndjson", encoder)
} }
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -272,8 +311,8 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
} }
return nil return nil
} }
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
if poll { if poll {
return s.sendOldMessages(t, since, sub) return s.sendOldMessages(t, since, sub)
} }
@@ -298,8 +337,8 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
} }
} }
func (s *Server) sendOldMessages(t *topic, since time.Time, sub subscriber) error { func (s *Server) sendOldMessages(t *topic, since sinceTime, sub subscriber) error {
if since.IsZero() { if since.IsNone() {
return nil return nil
} }
messages, err := s.cache.Messages(t.id, since) messages, err := s.cache.Messages(t.id, since)
@@ -314,17 +353,27 @@ func (s *Server) sendOldMessages(t *topic, since time.Time, sub subscriber) erro
return nil return nil
} }
func parseSince(r *http.Request) (time.Time, error) { // parseSince returns a timestamp identifying the time span from which cached messages should be received.
//
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h), or
// "all" for all messages.
func parseSince(r *http.Request) (sinceTime, error) {
if !r.URL.Query().Has("since") { if !r.URL.Query().Has("since") {
return time.Time{}, nil if r.URL.Query().Has("poll") {
return sinceAllMessages, nil
}
return sinceNoMessages, nil
} }
if since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64); err == nil { if r.URL.Query().Get("since") == "all" {
return time.Unix(since, 0), nil return sinceAllMessages, nil
}
if s, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64); err == nil {
return sinceTime(time.Unix(s, 0)), nil
} }
if d, err := time.ParseDuration(r.URL.Query().Get("since")); err == nil { if d, err := time.ParseDuration(r.URL.Query().Get("since")); err == nil {
return time.Now().Add(-1 * d), nil return sinceTime(time.Now().Add(-1 * d)), nil
} }
return time.Time{}, errHTTPBadRequest return sinceNoMessages, errHTTPBadRequest
} }
func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
@@ -360,7 +409,7 @@ func (s *Server) updateStatsAndExpire() {
} }
// Prune cache // Prune cache
if err := s.cache.Prune(s.config.MessageBufferDuration); err != nil { if err := s.cache.Prune(s.config.CacheDuration); err != nil {
log.Printf("error pruning cache: %s", err.Error()) log.Printf("error pruning cache: %s", err.Error())
} }
@@ -386,15 +435,27 @@ func (s *Server) updateStatsAndExpire() {
s.messages, len(s.topics), subscribers, messages, len(s.visitors)) s.messages, len(s.topics), subscribers, messages, len(s.visitors))
} }
func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
v := s.visitor(r)
if err := v.RequestAllowed(); err != nil {
return err
}
return handler(w, r, v)
}
// visitor creates or retrieves a rate.Limiter for the given visitor. // visitor creates or retrieves a rate.Limiter for the given visitor.
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
func (s *Server) visitor(remoteAddr string) *visitor { func (s *Server) visitor(r *http.Request) *visitor {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
remoteAddr := r.RemoteAddr
ip, _, err := net.SplitHostPort(remoteAddr) ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil { if err != nil {
ip = remoteAddr // This should not happen in real life; only in tests. ip = remoteAddr // This should not happen in real life; only in tests.
} }
if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" {
ip = r.Header.Get("X-Forwarded-For")
}
v, exists := s.visitors[ip] v, exists := s.visitors[ip]
if !exists { if !exists {
s.visitors[ip] = newVisitor(s.config) s.visitors[ip] = newVisitor(s.config)

View File

@@ -6,6 +6,12 @@ html, body {
font-size: 1.1em; font-size: 1.1em;
} }
html {
/* prevent scrollbar from repositioning website:
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
overflow-y: scroll;
}
a, a:visited { a, a:visited {
color: #3a9784; color: #3a9784;
} }
@@ -58,6 +64,7 @@ code {
border-radius: 3px; border-radius: 3px;
margin-top: 10px; margin-top: 10px;
margin-bottom: 20px; margin-bottom: 20px;
overflow-x: auto;
} }
/* Lato font (OFL), https://fonts.google.com/specimen/Lato#about, /* Lato font (OFL), https://fonts.google.com/specimen/Lato#about,
@@ -87,3 +94,221 @@ code {
#ironicCenterTagDontFreakOut { #ironicCenterTagDontFreakOut {
color: #666; color: #666;
} }
/* Subscribe box */
button {
background: #3a9784;
border: none;
border-radius: 3px;
padding: 3px 5px;
color: white;
cursor: pointer;
}
button:hover {
background: #317f6f;
padding: 5px;
}
ul {
padding-left: 1em;
list-style-type: circle;
padding-bottom: 0;
margin: 0;
}
li {
padding: 4px 0;
margin: 4px 0;
font-size: 0.9em;
}
/* Subscribe box SMALL SCREEN */
@media only screen and (max-width: 1599px) {
#subscribeBox #subscribeForm {
border-left: 4px solid #3a9784;
padding: 10px;
}
#subscribeBox #topicsHeader {
margin-bottom: 0;
}
#subscribeBox input {
height: 24px;
min-width: 200px;
max-width: 300px;
border-radius: 3px;
border: none;
border-bottom: 1px solid #aaa;
font-size: 0.8em;
}
#subscribeBox input:focus {
border-bottom: 2px solid #3a9784;
outline: none;
}
#subscribeBox ul {
margin: 0;
}
#subscribeBox li {
margin: 3px 0;
padding: 0;
}
#subscribeBox li img {
width: 15px;
height: 15px;
vertical-align: bottom;
}
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button {
font-size: 0.8em;
background: #3a9784;
border-radius: 3px;
padding: 5px;
color: white;
cursor: pointer;
}
#subscribeBox button:hover {
background: #317f6f;
}
}
/* Subscribe box BIG SCREEN */
@media only screen and (min-width: 1600px) {
#subscribeBox {
position: fixed;
top: 170px;
right: 10px;
width: 300px;
border-left: 4px solid #3a9784;
padding: 10px;
}
#subscribeBox h3 {
margin-top: 0;
margin-bottom: 5px;
font-size: 1.1em;
}
#subscribeBox #topicsHeader {
margin-bottom: 0;
}
#subscribeBox p {
font-size: 0.9em;
margin-bottom: 10px;
}
#subscribeBox ul {
margin: 0;
}
#subscribeBox input {
height: 18px;
border-radius: 3px;
border: none;
border-bottom: 1px solid #aaa;
}
#subscribeBox input:focus {
border-bottom: 2px solid #3a9784;
outline: none;
}
#subscribeBox li {
margin: 3px 0;
padding: 0;
}
#subscribeBox li img {
width: 15px;
height: 15px;
vertical-align: bottom;
}
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button {
font-size: 0.7em;
background: #3a9784;
border-radius: 3px;
padding: 5px;
color: white;
cursor: pointer;
}
#subscribeBox button:hover {
background: #317f6f;
}
}
/** Detail view */
#detail {
display: none;
position: absolute;
z-index: 1;
left: 8px;
right: 8px;
top: 0;
bottom: 0;
background: white;
}
#detail .detailDate {
color: #888;
font-size: 0.9em;
}
#detail .detailMessage {
margin-bottom: 20px;
font-size: 1.1em;
}
#detail #detailMain {
max-width: 900px;
margin: 0 auto;
position: relative; /* required for close button's "position: absolute" */
padding-bottom: 50px; /* Chrome and Firefox behave differently regarding bottom margin */
}
#detail #detailCloseButton {
background: #eee;
border-radius: 5px;
border: none;
padding: 5px;
position: absolute;
right: 0;
top: 10px;
display: block;
}
#detail #detailCloseButton:hover {
padding: 5px;
background: #ccc;
}
#detail #detailCloseButton img {
display: block; /* get rid of the weird bottom border */
}
#detail #detailNotificationsDisallowed {
display: none;
color: darkred;
}
#detail #events {
max-width: 900px;
margin: 0 auto 50px auto;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ffffff"><path d="M0 0h24v24H0z" fill="none"/><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@@ -10,37 +10,50 @@
/* All the things */ /* All the things */
let topics = {}; let topics = {};
let currentTopic = "";
let currentTopicUnsubscribeOnClose = false;
/* Main view */
const main = document.getElementById("main");
const topicsHeader = document.getElementById("topicsHeader"); const topicsHeader = document.getElementById("topicsHeader");
const topicsList = document.getElementById("topicsList"); const topicsList = document.getElementById("topicsList");
const topicField = document.getElementById("topicField"); const topicField = document.getElementById("topicField");
const notifySound = document.getElementById("notifySound"); const notifySound = document.getElementById("notifySound");
const subscribeButton = document.getElementById("subscribeButton"); const subscribeButton = document.getElementById("subscribeButton");
const subscribeForm = document.getElementById("subscribeForm");
const errorField = document.getElementById("error"); const errorField = document.getElementById("error");
const originalTitle = document.title;
/* Detail view */
const detailView = document.getElementById("detail");
const detailTitle = document.getElementById("detailTitle");
const detailEventsList = document.getElementById("detailEventsList");
const detailTopicUrl = document.getElementById("detailTopicUrl");
const detailNoNotifications = document.getElementById("detailNoNotifications");
const detailCloseButton = document.getElementById("detailCloseButton");
const detailNotificationsDisallowed = document.getElementById("detailNotificationsDisallowed");
const subscribe = (topic) => { const subscribe = (topic) => {
if (Notification.permission !== "granted") { if (Notification.permission !== "granted") {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
if (permission === "granted") { if (permission === "granted") {
subscribeInternal(topic, 0); subscribeInternal(topic, true, 0);
} else { } else {
showNotificationDeniedError(); showNotificationDeniedError();
} }
}); });
} else { } else {
subscribeInternal(topic, 0); subscribeInternal(topic, true,0);
} }
}; };
const subscribeInternal = (topic, delaySec) => { const subscribeInternal = (topic, persist, delaySec) => {
setTimeout(() => { setTimeout(() => {
// Render list entry // Render list entry
let topicEntry = document.getElementById(`topic-${topic}`); let topicEntry = document.getElementById(`topic-${topic}`);
if (!topicEntry) { if (!topicEntry) {
topicEntry = document.createElement('li'); topicEntry = document.createElement('li');
topicEntry.id = `topic-${topic}`; topicEntry.id = `topic-${topic}`;
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
topicsList.appendChild(topicEntry); topicsList.appendChild(topicEntry);
} }
topicsHeader.style.display = ''; topicsHeader.style.display = '';
@@ -48,30 +61,47 @@ const subscribeInternal = (topic, delaySec) => {
// Open event source // Open event source
let eventSource = new EventSource(`${topic}/sse`); let eventSource = new EventSource(`${topic}/sse`);
eventSource.onopen = () => { eventSource.onopen = () => {
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
delaySec = 0; // Reset on successful connection delaySec = 0; // Reset on successful connection
}; };
eventSource.onerror = (e) => { eventSource.onerror = (e) => {
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
eventSource.close();
const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15; const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; subscribeInternal(topic, persist, newDelaySec);
eventSource.close()
subscribeInternal(topic, newDelaySec);
}; };
eventSource.onmessage = (e) => { eventSource.onmessage = (e) => {
const event = JSON.parse(e.data); const event = JSON.parse(e.data);
notifySound.play(); topics[topic]['messages'].push(event);
new Notification(`${location.host}/${topic}`, { topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
body: event.message, if (currentTopic === topic) {
icon: '/static/img/favicon.png' rerenderDetailView();
}); }
if (Notification.permission === "granted") {
notifySound.play();
new Notification(`${location.host}/${topic}`, {
body: event.message,
icon: '/static/img/favicon.png'
});
}
}; };
topics[topic] = eventSource; topics[topic] = {
localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); 'eventSource': eventSource,
'messages': [],
'persist': persist
};
fetchCachedMessages(topic).then(() => {
if (currentTopic === topic) {
rerenderDetailView();
}
})
let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist);
localStorage.setItem('topics', JSON.stringify(persistedTopicKeys));
}, delaySec * 1000); }, delaySec * 1000);
}; };
const unsubscribe = (topic) => { const unsubscribe = (topic) => {
topics[topic].close(); topics[topic]['eventSource'].close();
delete topics[topic]; delete topics[topic];
localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
document.getElementById(`topic-${topic}`).remove(); document.getElementById(`topic-${topic}`).remove();
@@ -83,8 +113,80 @@ const unsubscribe = (topic) => {
const test = (topic) => { const test = (topic) => {
fetch(`/${topic}`, { fetch(`/${topic}`, {
method: 'PUT', method: 'PUT',
body: `This is a test notification` body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`
});
};
const fetchCachedMessages = async (topic) => {
const topicJsonUrl = `/${topic}/json?poll=1`; // Poll!
for await (let line of makeTextFileLineIterator(topicJsonUrl)) {
const message = JSON.parse(line);
topics[topic]['messages'].push(message);
}
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
};
const showDetail = (topic) => {
currentTopic = topic;
history.replaceState(topic, `ntfy.sh/${topic}`, `/${topic}`);
window.scrollTo(0, 0);
rerenderDetailView();
return false;
};
const rerenderDetailView = () => {
detailTitle.innerHTML = `ntfy.sh/${currentTopic}`; // document.location.replaceAll(..)
detailTopicUrl.innerHTML = `ntfy.sh/${currentTopic}`;
while (detailEventsList.firstChild) {
detailEventsList.removeChild(detailEventsList.firstChild);
}
topics[currentTopic]['messages'].forEach(m => {
let dateDiv = document.createElement('div');
let messageDiv = document.createElement('div');
let eventDiv = document.createElement('div');
dateDiv.classList.add('detailDate');
dateDiv.innerHTML = new Date(m.time * 1000).toLocaleString();
messageDiv.classList.add('detailMessage');
messageDiv.innerText = m.message;
eventDiv.appendChild(dateDiv);
eventDiv.appendChild(messageDiv);
detailEventsList.appendChild(eventDiv);
}) })
if (topics[currentTopic]['messages'].length === 0) {
detailNoNotifications.style.display = '';
} else {
detailNoNotifications.style.display = 'none';
}
if (Notification.permission === "granted") {
detailNotificationsDisallowed.style.display = 'none';
} else {
detailNotificationsDisallowed.style.display = 'block';
}
detailView.style.display = 'block';
main.style.display = 'none';
};
const hideDetailView = () => {
if (currentTopicUnsubscribeOnClose) {
unsubscribe(currentTopic);
currentTopicUnsubscribeOnClose = false;
}
currentTopic = "";
history.replaceState('', originalTitle, '/');
detailView.style.display = 'none';
main.style.display = '';
return false;
};
const requestPermission = () => {
if (Notification.permission !== "granted") {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
detailNotificationsDisallowed.style.display = 'none';
}
});
}
return false;
}; };
const showError = (msg) => { const showError = (msg) => {
@@ -101,7 +203,39 @@ const showNotificationDeniedError = () => {
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications."); showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
}; };
subscribeForm.onsubmit = function () { // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
async function* makeTextFileLineIterator(fileURL) {
const utf8Decoder = new TextDecoder('utf-8');
const response = await fetch(fileURL);
const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read();
chunk = chunk ? utf8Decoder.decode(chunk) : '';
const re = /\n|\r|\r\n/gm;
let startIndex = 0;
let result;
for (;;) {
let result = re.exec(chunk);
if (!result) {
if (readerDone) {
break;
}
let remainder = chunk.substr(startIndex);
({ value: chunk, done: readerDone } = await reader.read());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
startIndex = re.lastIndex = 0;
continue;
}
yield chunk.substring(startIndex, result.index);
startIndex = re.lastIndex;
}
if (startIndex < chunk.length) {
yield chunk.substr(startIndex); // last line didn't end in a newline char
}
}
subscribeButton.onclick = () => {
if (!topicField.value) { if (!topicField.value) {
return false; return false;
} }
@@ -110,6 +244,10 @@ subscribeForm.onsubmit = function () {
return false; return false;
}; };
detailCloseButton.onclick = () => {
hideDetailView();
};
// Disable Web UI if notifications of EventSource are not available // Disable Web UI if notifications of EventSource are not available
if (!window["Notification"] || !window["EventSource"]) { if (!window["Notification"] || !window["EventSource"]) {
showBrowserIncompatibleError(); showBrowserIncompatibleError();
@@ -121,13 +259,22 @@ if (!window["Notification"] || !window["EventSource"]) {
topicField.value = ""; topicField.value = "";
// Restore topics // Restore topics
const storedTopics = localStorage.getItem('topics'); const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
if (storedTopics && Notification.permission === "granted") { if (storedTopics) {
const storedTopicsArray = JSON.parse(storedTopics) storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); }); if (storedTopics.length === 0) {
if (storedTopicsArray.length === 0) {
topicsHeader.style.display = 'none'; topicsHeader.style.display = 'none';
} }
} else { } else {
topicsHeader.style.display = 'none'; topicsHeader.style.display = 'none';
} }
// (Temporarily) subscribe topic if we navigated to /sometopic URL
const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app!
if (match) {
currentTopic = match[1];
if (!storedTopics.includes(currentTopic)) {
subscribeInternal(currentTopic, false,0);
currentTopicUnsubscribeOnClose = true;
}
}

View File

@@ -24,7 +24,7 @@ type visitor struct {
func newVisitor(conf *config.Config) *visitor { func newVisitor(conf *config.Config) *visitor {
return &visitor{ return &visitor{
config: conf, config: conf,
limiter: rate.NewLimiter(conf.VisitorRequestLimit, conf.VisitorRequestLimitBurst), limiter: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)), subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
seen: time.Now(), seen: time.Now(),
} }

View File

@@ -1,6 +1,7 @@
package util package util
import ( import (
"fmt"
"math/rand" "math/rand"
"os" "os"
"time" "time"
@@ -27,3 +28,35 @@ func RandomString(length int) string {
} }
return string(b) return string(b)
} }
// DurationToHuman converts a duration to a human readable format
func DurationToHuman(d time.Duration) (str string) {
if d == 0 {
return "0"
}
d = d.Round(time.Second)
days := d / time.Hour / 24
if days > 0 {
str += fmt.Sprintf("%dd", days)
}
d -= days * time.Hour * 24
hours := d / time.Hour
if hours > 0 {
str += fmt.Sprintf("%dh", hours)
}
d -= hours * time.Hour
minutes := d / time.Minute
if minutes > 0 {
str += fmt.Sprintf("%dm", minutes)
}
d -= minutes * time.Minute
seconds := d / time.Second
if seconds > 0 {
str += fmt.Sprintf("%ds", seconds)
}
return
}