mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
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: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: config.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "default 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: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: config.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: config.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"V"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: config.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"B"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: config.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"R"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: config.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
}
|
||||
return &cli.App{
|
||||
Name: "ntfy",
|
||||
@@ -50,6 +55,11 @@ func execRun(c *cli.Context) error {
|
||||
cacheDuration := c.Duration("cache-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
globalTopicLimit := c.Int("global-topic-limit")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
@@ -69,6 +79,11 @@ func execRun(c *cli.Context) error {
|
||||
conf.CacheDuration = cacheDuration
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.GlobalTopicLimit = globalTopicLimit
|
||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||
conf.BehindProxy = behindProxy
|
||||
s, err := server.New(conf)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"golang.org/x/time/rate"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,42 +14,44 @@ const (
|
||||
)
|
||||
|
||||
// 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
|
||||
// - subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||
var (
|
||||
defaultGlobalTopicLimit = 5000
|
||||
defaultVisitorRequestLimit = rate.Every(10 * time.Second)
|
||||
defaultVisitorRequestLimitBurst = 50
|
||||
defaultVisitorSubscriptionLimit = 30
|
||||
// - per visistor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
|
||||
// - per visistor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||
const (
|
||||
DefaultGlobalTopicLimit = 5000
|
||||
DefaultVisitorRequestLimitBurst = 60
|
||||
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
||||
DefaultVisitorSubscriptionLimit = 30
|
||||
)
|
||||
|
||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||
type Config struct {
|
||||
ListenHTTP string
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
GlobalTopicLimit int
|
||||
VisitorRequestLimit rate.Limit
|
||||
VisitorRequestLimitBurst int
|
||||
VisitorSubscriptionLimit int
|
||||
ListenHTTP string
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
GlobalTopicLimit int
|
||||
VisitorRequestLimitBurst int
|
||||
VisitorRequestLimitReplenish time.Duration
|
||||
VisitorSubscriptionLimit int
|
||||
BehindProxy bool
|
||||
}
|
||||
|
||||
// New instantiates a default new config
|
||||
func New(listenHTTP string) *Config {
|
||||
return &Config{
|
||||
ListenHTTP: listenHTTP,
|
||||
FirebaseKeyFile: "",
|
||||
CacheFile: "",
|
||||
CacheDuration: DefaultCacheDuration,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
GlobalTopicLimit: defaultGlobalTopicLimit,
|
||||
VisitorRequestLimit: defaultVisitorRequestLimit,
|
||||
VisitorRequestLimitBurst: defaultVisitorRequestLimitBurst,
|
||||
VisitorSubscriptionLimit: defaultVisitorSubscriptionLimit,
|
||||
ListenHTTP: listenHTTP,
|
||||
FirebaseKeyFile: "",
|
||||
CacheFile: "",
|
||||
CacheDuration: DefaultCacheDuration,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
GlobalTopicLimit: DefaultGlobalTopicLimit,
|
||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||
BehindProxy: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,30 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
# 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>
|
||||
<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.
|
||||
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's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
|
||||
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,
|
||||
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 id="error"></p>
|
||||
|
||||
<h2>Publishing messages</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h3>Subscribe via web</h3>
|
||||
<p>
|
||||
If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic
|
||||
will show up as <b>desktop notification</b>.
|
||||
</p>
|
||||
<form id="subscribeForm">
|
||||
<div id="subscribeBox">
|
||||
<h3>Subscribe in this Web UI</h3>
|
||||
<p id="error"></p>
|
||||
<p>
|
||||
<label for="topicField">Subscribe to topic:</label>
|
||||
<input type="text" id="topicField" placeholder="Letters, numbers, _ and -" pattern="[-_A-Za-z]{1,64}" />
|
||||
<input type="submit" id="subscribeButton" value="Subscribe" />
|
||||
Subscribe to topics here and receive messages as <b>desktop notification</b>. Topics are not password-protected,
|
||||
so choose a name that's not easy to guess. Once subscribed, you can publish messages via PUT/POST.
|
||||
</p>
|
||||
</form>
|
||||
<p id="topicsHeader">Topics:</p>
|
||||
<ul id="topicsList"></ul>
|
||||
<audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
|
||||
<form id="subscribeForm">
|
||||
<p>
|
||||
<b>Topic:</b><br/>
|
||||
<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>
|
||||
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>.
|
||||
@@ -128,6 +135,14 @@
|
||||
<br/>
|
||||
This is a notification
|
||||
</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>
|
||||
<p class="smallMarginBottom">
|
||||
@@ -177,8 +192,7 @@
|
||||
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
|
||||
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>>.
|
||||
I'm sorry.
|
||||
versions of Android made it impossible to implement <a href="https://developer.android.com/guide/background">background services</a>.
|
||||
</p>
|
||||
|
||||
<h2>Privacy policy</h2>
|
||||
@@ -188,7 +202,6 @@
|
||||
is the Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||
FAQ for details).
|
||||
</p>
|
||||
|
||||
<p>
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
v := s.visitor(r.RemoteAddr)
|
||||
if err := v.RequestAllowed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
||||
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) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -186,6 +184,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
||||
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 {
|
||||
http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
|
||||
return nil
|
||||
@@ -394,15 +396,27 @@ func (s *Server) updateStatsAndExpire() {
|
||||
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.
|
||||
// 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()
|
||||
defer s.mu.Unlock()
|
||||
remoteAddr := r.RemoteAddr
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
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]
|
||||
if !exists {
|
||||
s.visitors[ip] = newVisitor(s.config)
|
||||
|
||||
@@ -58,6 +58,7 @@ code {
|
||||
border-radius: 3px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Lato font (OFL), https://fonts.google.com/specimen/Lato#about,
|
||||
@@ -87,3 +88,159 @@ code {
|
||||
#ironicCenterTagDontFreakOut {
|
||||
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 notifySound = document.getElementById("notifySound");
|
||||
const subscribeButton = document.getElementById("subscribeButton");
|
||||
const subscribeForm = document.getElementById("subscribeForm");
|
||||
const errorField = document.getElementById("error");
|
||||
|
||||
const subscribe = (topic) => {
|
||||
@@ -40,7 +39,7 @@ const subscribeInternal = (topic, delaySec) => {
|
||||
if (!topicEntry) {
|
||||
topicEntry = document.createElement('li');
|
||||
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);
|
||||
}
|
||||
topicsHeader.style.display = '';
|
||||
@@ -48,13 +47,13 @@ const subscribeInternal = (topic, delaySec) => {
|
||||
// Open event source
|
||||
let eventSource = new EventSource(`${topic}/sse`);
|
||||
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
|
||||
};
|
||||
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;
|
||||
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);
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -83,7 +82,7 @@ const unsubscribe = (topic) => {
|
||||
const test = (topic) => {
|
||||
fetch(`/${topic}`, {
|
||||
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.");
|
||||
};
|
||||
|
||||
subscribeForm.onsubmit = function () {
|
||||
subscribeButton.onclick = function () {
|
||||
if (!topicField.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type visitor struct {
|
||||
func newVisitor(conf *config.Config) *visitor {
|
||||
return &visitor{
|
||||
config: conf,
|
||||
limiter: rate.NewLimiter(conf.VisitorRequestLimit, conf.VisitorRequestLimitBurst),
|
||||
limiter: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
||||
subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
seen: time.Now(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user