Philipp Heckel hace 4 años
padre
commit
b775e6dfce
Se han modificado 5 ficheros con 146 adiciones y 37 borrados
  1. 26 21
      config/config.go
  2. 29 0
      server/index.html
  3. 18 8
      server/server.go
  4. 8 8
      server/visitor.go
  5. 65 0
      util/limit.go

+ 26 - 21
config/config.go

@@ -14,36 +14,41 @@ const (
 	DefaultManagerInterval       = time.Minute
 	DefaultManagerInterval       = time.Minute
 )
 )
 
 
-// Defines the max number of requests, here:
-// 50 requests bucket, replenished at a rate of 1 per second
+// 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
+// - subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
 var (
 var (
-	defaultRequestLimit      = rate.Every(time.Second)
-	defaultRequestLimitBurst = 50
-	defaultSubscriptionLimit = 30 // per visitor
+	defaultGlobalTopicLimit         = 5000
+	defaultVisitorRequestLimit      = rate.Every(time.Second)
+	defaultVisitorRequestLimitBurst = 50
+	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
-	FirebaseKeyFile       string
-	MessageBufferDuration time.Duration
-	KeepaliveInterval     time.Duration
-	ManagerInterval       time.Duration
-	RequestLimit          rate.Limit
-	RequestLimitBurst     int
-	SubscriptionLimit     int
+	ListenHTTP               string
+	FirebaseKeyFile          string
+	MessageBufferDuration    time.Duration
+	KeepaliveInterval        time.Duration
+	ManagerInterval          time.Duration
+	GlobalTopicLimit         int
+	VisitorRequestLimit      rate.Limit
+	VisitorRequestLimitBurst int
+	VisitorSubscriptionLimit int
 }
 }
 
 
 // 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,
-		FirebaseKeyFile:       "",
-		MessageBufferDuration: DefaultMessageBufferDuration,
-		KeepaliveInterval:     DefaultKeepaliveInterval,
-		ManagerInterval:       DefaultManagerInterval,
-		RequestLimit:          defaultRequestLimit,
-		RequestLimitBurst:     defaultRequestLimitBurst,
-		SubscriptionLimit:     defaultSubscriptionLimit,
+		ListenHTTP:               listenHTTP,
+		FirebaseKeyFile:          "",
+		MessageBufferDuration:    DefaultMessageBufferDuration,
+		KeepaliveInterval:        DefaultKeepaliveInterval,
+		ManagerInterval:          DefaultManagerInterval,
+		GlobalTopicLimit:         defaultGlobalTopicLimit,
+		VisitorRequestLimit:      defaultVisitorRequestLimit,
+		VisitorRequestLimitBurst: defaultVisitorRequestLimitBurst,
+		VisitorSubscriptionLimit: defaultVisitorSubscriptionLimit,
 	}
 	}
 }
 }

+ 29 - 0
server/index.html

@@ -81,6 +81,12 @@
     <ul id="topicsList"></ul>
     <ul id="topicsList"></ul>
     <audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
     <audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
 
 
+    <h3>Subscribe via phone</h3>
+    <p>
+        Once it's approved, you can use the <b>Ntfy Android App</b> 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>
+
     <h3>Subscribe via your app, or via the CLI</h3>
     <h3>Subscribe via your app, or via the CLI</h3>
     <p class="smallMarginBottom">
     <p class="smallMarginBottom">
         Using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a> in JS, you can consume
         Using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a> in JS, you can consume
@@ -142,6 +148,7 @@
         $ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m"<br/>
         $ curl -s "ntfy.sh/mytopic/json?poll=1&since=10m"<br/>
         # Returns messages from up to 10 minutes ago and ends the connection
         # Returns messages from up to 10 minutes ago and ends the connection
     </code>
     </code>
+
     <h2>FAQ</h2>
     <h2>FAQ</h2>
     <p>
     <p>
         <b>Isn't this like ...?</b><br/>
         <b>Isn't this like ...?</b><br/>
@@ -165,6 +172,28 @@
         That said, the logs do not contain any topic names or other details about you. Check the code if you don't believe me.
         That said, the logs do not contain any topic names or other details about you. Check the code if you don't believe me.
     </p>
     </p>
 
 
+    <p>
+        <b>Why is Firebase used?</b><br/>
+        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.
+    </p>
+
+    <h2>Privacy policy</h2>
+    <p>
+        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
+        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 the <tt>since=</tt> feature and service restarts.
+    </p>
+
     <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>
 <script src="static/js/app.js"></script>
 <script src="static/js/app.js"></script>

+ 18 - 8
server/server.go

@@ -24,6 +24,7 @@ import (
 
 
 // TODO add "max messages in a topic" limit
 // TODO add "max messages in a topic" limit
 // TODO implement persistence
 // TODO implement persistence
+// TODO implement "since=<ID>"
 
 
 // Server is the main server
 // Server is the main server
 type Server struct {
 type Server struct {
@@ -146,7 +147,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
 	} 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) {
 	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
-		return s.handlePublish(w, r)
+		return s.handlePublish(w, r, v)
 	} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
 	} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
 		return s.handleSubscribeJSON(w, r, v)
 		return s.handleSubscribeJSON(w, r, v)
 	} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
 	} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
@@ -169,8 +170,11 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
 	return nil
 	return nil
 }
 }
 
 
-func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request) error {
-	t := s.createTopic(r.URL.Path[1:])
+func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	t, err := s.topic(r.URL.Path[1:])
+	if err != nil {
+		return err
+	}
 	reader := io.LimitReader(r.Body, messageLimit)
 	reader := io.LimitReader(r.Body, messageLimit)
 	b, err := io.ReadAll(reader)
 	b, err := io.ReadAll(reader)
 	if err != nil {
 	if err != nil {
@@ -223,10 +227,13 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
 
 
 func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
 func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
 	if err := v.AddSubscription(); err != nil {
 	if err := v.AddSubscription(); err != nil {
-		return err
+		return errHTTPTooManyRequests
 	}
 	}
 	defer v.RemoveSubscription()
 	defer v.RemoveSubscription()
-	t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/"+format)) // Hack
+	t, err := s.topic(strings.TrimSuffix(r.URL.Path[1:], "/"+format)) // Hack
+	if err != nil {
+		return err
+	}
 	since, err := parseSince(r)
 	since, err := parseSince(r)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -304,16 +311,19 @@ func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
 	return nil
 	return nil
 }
 }
 
 
-func (s *Server) createTopic(id string) *topic {
+func (s *Server) topic(id string) (*topic, error) {
 	s.mu.Lock()
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	defer s.mu.Unlock()
 	if _, ok := s.topics[id]; !ok {
 	if _, ok := s.topics[id]; !ok {
+		if len(s.topics) >= s.config.GlobalTopicLimit {
+			return nil, errHTTPTooManyRequests
+		}
 		s.topics[id] = newTopic(id)
 		s.topics[id] = newTopic(id)
 		if s.firebase != nil {
 		if s.firebase != nil {
 			s.topics[id].Subscribe(s.firebase)
 			s.topics[id].Subscribe(s.firebase)
 		}
 		}
 	}
 	}
-	return s.topics[id]
+	return s.topics[id], nil
 }
 }
 
 
 func (s *Server) updateStatsAndExpire() {
 func (s *Server) updateStatsAndExpire() {
@@ -331,7 +341,7 @@ func (s *Server) updateStatsAndExpire() {
 	for _, t := range s.topics {
 	for _, t := range s.topics {
 		t.Prune(s.config.MessageBufferDuration)
 		t.Prune(s.config.MessageBufferDuration)
 		subs, msgs := t.Stats()
 		subs, msgs := t.Stats()
-		if msgs == 0 && (subs == 0 || (s.firebase != nil && subs == 1)) {
+		if msgs == 0 && (subs == 0 || (s.firebase != nil && subs == 1)) { // Firebase is a subscriber!
 			delete(s.topics, t.id)
 			delete(s.topics, t.id)
 		}
 		}
 	}
 	}

+ 8 - 8
server/visitor.go

@@ -3,6 +3,7 @@ package server
 import (
 import (
 	"golang.org/x/time/rate"
 	"golang.org/x/time/rate"
 	"heckel.io/ntfy/config"
 	"heckel.io/ntfy/config"
+	"heckel.io/ntfy/util"
 	"sync"
 	"sync"
 	"time"
 	"time"
 )
 )
@@ -15,16 +16,17 @@ const (
 type visitor struct {
 type visitor struct {
 	config        *config.Config
 	config        *config.Config
 	limiter       *rate.Limiter
 	limiter       *rate.Limiter
-	subscriptions int
+	subscriptions *util.Limiter
 	seen          time.Time
 	seen          time.Time
 	mu            sync.Mutex
 	mu            sync.Mutex
 }
 }
 
 
 func newVisitor(conf *config.Config) *visitor {
 func newVisitor(conf *config.Config) *visitor {
 	return &visitor{
 	return &visitor{
-		config:  conf,
-		limiter: rate.NewLimiter(conf.RequestLimit, conf.RequestLimitBurst),
-		seen:    time.Now(),
+		config:        conf,
+		limiter:       rate.NewLimiter(conf.VisitorRequestLimit, conf.VisitorRequestLimitBurst),
+		subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
+		seen:          time.Now(),
 	}
 	}
 }
 }
 
 
@@ -38,17 +40,16 @@ func (v *visitor) RequestAllowed() error {
 func (v *visitor) AddSubscription() error {
 func (v *visitor) AddSubscription() error {
 	v.mu.Lock()
 	v.mu.Lock()
 	defer v.mu.Unlock()
 	defer v.mu.Unlock()
-	if v.subscriptions >= v.config.SubscriptionLimit {
+	if err := v.subscriptions.Add(1); err != nil {
 		return errHTTPTooManyRequests
 		return errHTTPTooManyRequests
 	}
 	}
-	v.subscriptions++
 	return nil
 	return nil
 }
 }
 
 
 func (v *visitor) RemoveSubscription() {
 func (v *visitor) RemoveSubscription() {
 	v.mu.Lock()
 	v.mu.Lock()
 	defer v.mu.Unlock()
 	defer v.mu.Unlock()
-	v.subscriptions--
+	v.subscriptions.Sub(1)
 }
 }
 
 
 func (v *visitor) Keepalive() {
 func (v *visitor) Keepalive() {
@@ -60,6 +61,5 @@ func (v *visitor) Keepalive() {
 func (v *visitor) Stale() bool {
 func (v *visitor) Stale() bool {
 	v.mu.Lock()
 	v.mu.Lock()
 	defer v.mu.Unlock()
 	defer v.mu.Unlock()
-	v.seen = time.Now()
 	return time.Since(v.seen) > visitorExpungeAfter
 	return time.Since(v.seen) > visitorExpungeAfter
 }
 }

+ 65 - 0
util/limit.go

@@ -0,0 +1,65 @@
+package util
+
+import (
+	"errors"
+	"sync"
+)
+
+// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached
+var ErrLimitReached = errors.New("limit reached")
+
+// Limiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
+// ErrLimitReached will be returned. Limiter may be used by multiple goroutines.
+type Limiter struct {
+	value int64
+	limit int64
+	mu    sync.Mutex
+}
+
+// NewLimiter creates a new Limiter
+func NewLimiter(limit int64) *Limiter {
+	return &Limiter{
+		limit: limit,
+	}
+}
+
+// Add adds n to the limiters internal value, but only if the limit has not been reached. If the limit would be
+// exceeded after adding n, ErrLimitReached is returned.
+func (l *Limiter) Add(n int64) error {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.limit == 0 {
+		l.value += n
+		return nil
+	} else if l.value+n <= l.limit {
+		l.value += n
+		return nil
+	} else {
+		return ErrLimitReached
+	}
+}
+
+// Sub subtracts a value from the limiters internal value
+func (l *Limiter) Sub(n int64) {
+	l.Add(-n)
+}
+
+// Set sets the value of the limiter to n. This function ignores the limit. It is meant to set the value
+// based on reality.
+func (l *Limiter) Set(n int64) {
+	l.mu.Lock()
+	l.value = n
+	l.mu.Unlock()
+}
+
+// Value returns the internal value of the limiter
+func (l *Limiter) Value() int64 {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	return l.value
+}
+
+// Limit returns the defined limit
+func (l *Limiter) Limit() int64 {
+	return l.limit
+}