Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0170f673bd | ||
|
|
86a16e3944 | ||
|
|
c9124cb5eb | ||
|
|
644ffa1420 |
19
cmd/app.go
19
cmd/app.go
@@ -22,8 +22,13 @@ func New() *cli.App {
|
|||||||
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.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: "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: "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: "default interval of keepalive messages"}),
|
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: "default interval of for message pruning and stats printing"}),
|
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",
|
||||||
@@ -50,6 +55,11 @@ func execRun(c *cli.Context) error {
|
|||||||
cacheDuration := c.Duration("cache-duration")
|
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) {
|
||||||
@@ -69,6 +79,11 @@ func execRun(c *cli.Context) error {
|
|||||||
conf.CacheDuration = cacheDuration
|
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)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/time/rate"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,42 +14,44 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 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 one per 10 seconds)
|
|
||||||
// - 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(10 * 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
|
||||||
FirebaseKeyFile string
|
FirebaseKeyFile string
|
||||||
CacheFile string
|
CacheFile string
|
||||||
CacheDuration 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,
|
||||||
FirebaseKeyFile: "",
|
FirebaseKeyFile: "",
|
||||||
CacheFile: "",
|
CacheFile: "",
|
||||||
CacheDuration: DefaultCacheDuration,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,30 @@
|
|||||||
#
|
#
|
||||||
# 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
|
||||||
|
|||||||
@@ -33,10 +33,14 @@
|
|||||||
<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>notifications to your phone or desktop 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,23 +69,26 @@
|
|||||||
<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" pattern="[-_A-Za-z]{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>
|
||||||
You can use the <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">Ntfy Android App</a>
|
You can use the <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">Ntfy Android App</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>.
|
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>.
|
||||||
@@ -128,6 +135,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/>
|
||||||
|
[ -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,8 +192,7 @@
|
|||||||
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>
|
||||||
@@ -188,7 +202,6 @@
|
|||||||
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.
|
||||||
|
|||||||
@@ -159,24 +159,22 @@ 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 err := v.RequestAllowed(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
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
|
||||||
}
|
}
|
||||||
@@ -186,6 +184,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
|
http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
|
||||||
return nil
|
return nil
|
||||||
@@ -394,15 +396,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)
|
||||||
|
|||||||
@@ -58,6 +58,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 +88,159 @@ 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: none;
|
||||||
|
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;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subscribeBox li {
|
||||||
|
margin: 3px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subscribeBox li img {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
padding: 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 button {
|
||||||
|
font-size: 0.7em;
|
||||||
|
background: #3a9784;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subscribeBox button:hover {
|
||||||
|
background: #317f6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
server/static/img/clear_black_24dp.svg
Normal file
1
server/static/img/clear_black_24dp.svg
Normal 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 |
1
server/static/img/send_black_24dp.svg
Normal file
1
server/static/img/send_black_24dp.svg
Normal 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 |
@@ -16,7 +16,6 @@ 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 subscribe = (topic) => {
|
const subscribe = (topic) => {
|
||||||
@@ -40,7 +39,7 @@ const subscribeInternal = (topic, delaySec) => {
|
|||||||
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 = `${topic} <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,13 +47,13 @@ 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 = `${topic} <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 = `${topic} <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>`;
|
|
||||||
eventSource.close()
|
|
||||||
subscribeInternal(topic, newDelaySec);
|
subscribeInternal(topic, newDelaySec);
|
||||||
};
|
};
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -83,7 +82,7 @@ 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()}.`
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +100,7 @@ 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 () {
|
subscribeButton.onclick = function () {
|
||||||
if (!topicField.value) {
|
if (!topicField.value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user