Subscribe endpoint consolidation; same behavior for all endpoints; keepalive
This commit is contained in:
		
							parent
							
								
									b72afb1695
								
							
						
					
					
						commit
						a38aca47bd
					
				
					 8 changed files with 154 additions and 93 deletions
				
			
		|  | @ -16,6 +16,7 @@ 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.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: config.DefaultKeepaliveInterval, Usage: "default interval of keepalive messages"}), | ||||||
| 	} | 	} | ||||||
| 	return &cli.App{ | 	return &cli.App{ | ||||||
| 		Name:                   "ntfy", | 		Name:                   "ntfy", | ||||||
|  | @ -37,9 +38,11 @@ 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") | ||||||
|  | 	keepaliveInterval := c.Duration("keepalive-interval") | ||||||
| 
 | 
 | ||||||
| 	// Run main bot, can be killed by signal | 	// Run main bot, can be killed by signal | ||||||
| 	conf := config.New(listenHTTP) | 	conf := config.New(listenHTTP) | ||||||
|  | 	conf.KeepaliveInterval = keepaliveInterval | ||||||
| 	s := server.New(conf) | 	s := server.New(conf) | ||||||
| 	if err := s.Run(); err != nil { | 	if err := s.Run(); err != nil { | ||||||
| 		log.Fatalln(err) | 		log.Fatalln(err) | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import ( | ||||||
| // Defines default config settings | // Defines default config settings | ||||||
| const ( | const ( | ||||||
| 	DefaultListenHTTP        = ":80" | 	DefaultListenHTTP        = ":80" | ||||||
|  | 	DefaultKeepaliveInterval = 30 * time.Second | ||||||
| 	defaultManagerInterval   = time.Minute | 	defaultManagerInterval   = time.Minute | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -24,6 +25,7 @@ type Config struct { | ||||||
| 	ListenHTTP        string | 	ListenHTTP        string | ||||||
| 	Limit             rate.Limit | 	Limit             rate.Limit | ||||||
| 	LimitBurst        int | 	LimitBurst        int | ||||||
|  | 	KeepaliveInterval time.Duration | ||||||
| 	ManagerInterval   time.Duration | 	ManagerInterval   time.Duration | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -33,6 +35,7 @@ func New(listenHTTP string) *Config { | ||||||
| 		ListenHTTP:        listenHTTP, | 		ListenHTTP:        listenHTTP, | ||||||
| 		Limit:             defaultLimit, | 		Limit:             defaultLimit, | ||||||
| 		LimitBurst:        defaultLimitBurst, | 		LimitBurst:        defaultLimitBurst, | ||||||
|  | 		KeepaliveInterval: DefaultKeepaliveInterval, | ||||||
| 		ManagerInterval:   defaultManagerInterval, | 		ManagerInterval:   defaultManagerInterval, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -65,20 +65,7 @@ | ||||||
|     <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 your app, or via the CLI</h3> |     <h3>Subscribe via your app, or via the CLI</h3> | ||||||
|     <p> |     <p class="smallMarginBottom"> | ||||||
|         Here are some examples using <tt>curl</tt>: |  | ||||||
|     </p> |  | ||||||
|     <code> |  | ||||||
|         # one message per line (\n are replaced with a space)<br/> |  | ||||||
|         curl -s ntfy.sh/mytopic/raw<br/><br/> |  | ||||||
| 
 |  | ||||||
|         # one JSON message per line<br/> |  | ||||||
|         curl -s ntfy.sh/mytopic/json<br/><br/> |  | ||||||
| 
 |  | ||||||
|         # server-sent events (SSE) stream, use with EventSource<br/> |  | ||||||
|         curl -s ntfy.sh/mytopic/sse |  | ||||||
|     </code> |  | ||||||
|     <p> |  | ||||||
|         Using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>, you can consume |         Using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>, you can consume | ||||||
|         notifications like this (see <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">full example</a>): |         notifications like this (see <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">full example</a>): | ||||||
|     </p> |     </p> | ||||||
|  | @ -88,15 +75,46 @@ | ||||||
|           // Do something with e.data<br/> |           // Do something with e.data<br/> | ||||||
|         }; |         }; | ||||||
|     </code> |     </code> | ||||||
|  |     <p class="smallMarginBottom"> | ||||||
|  |         Or you can use <tt>curl</tt> or any other HTTP library. Here's an example for the <tt>/json</tt> endpoint, | ||||||
|  |         which prints one JSON message per line (keepalive and open messages have an "event" field): | ||||||
|  |     </p> | ||||||
|  |     <code> | ||||||
|  |         $ curl -s ntfy.sh/mytopic/json<br/> | ||||||
|  |         {"time":1635359841,"event":"open"}<br/> | ||||||
|  |         {"time":1635359844,"message":"This is a notification"}<br/> | ||||||
|  |         {"time":1635359851,"event":"keepalive"} | ||||||
|  |     </code> | ||||||
|  |     <p class="smallMarginBottom"> | ||||||
|  |         Using the <tt>/sse</tt> endpoint (SSE, server-sent events stream): | ||||||
|  |     </p> | ||||||
|  |     <code> | ||||||
|  |         $ curl -s ntfy.sh/mytopic/sse<br/> | ||||||
|  |         event: open<br/> | ||||||
|  |         data: {"time":1635359796,"event":"open"}<br/><br/> | ||||||
|  | 
 | ||||||
|  |         data: {"time":1635359803,"message":"This is a notification"}<br/><br/> | ||||||
|  | 
 | ||||||
|  |         event: keepalive<br/> | ||||||
|  |         data: {"time":1635359806,"event":"keepalive"} | ||||||
|  |     </code> | ||||||
|  |     <p class="smallMarginBottom"> | ||||||
|  |         Using the <tt>/raw</tt> endpoint (empty lines are keepalive messages): | ||||||
|  |     </p> | ||||||
|  |     <code> | ||||||
|  |         $ curl -s ntfy.sh/mytopic/raw<br/> | ||||||
|  |         <br/> | ||||||
|  |         This is a notification | ||||||
|  |     </code> | ||||||
| 
 | 
 | ||||||
|     <h2>Publishing messages</h2> |     <h2>Publishing messages</h2> | ||||||
|     <p> |     <p class="smallMarginBottom"> | ||||||
|         Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>: |         Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>: | ||||||
|     </p> |     </p> | ||||||
|     <code> |     <code> | ||||||
|         curl -d "long process is done" ntfy.sh/mytopic |         curl -d "long process is done" ntfy.sh/mytopic | ||||||
|     </code> |     </code> | ||||||
|     <p> |     <p class="smallMarginBottom"> | ||||||
|         Here's an example in JS with <tt>fetch()</tt> (see <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">full example</a>): |         Here's an example in JS with <tt>fetch()</tt> (see <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">full example</a>): | ||||||
|     </p> |     </p> | ||||||
|     <code> |     <code> | ||||||
|  |  | ||||||
							
								
								
									
										43
									
								
								server/message.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								server/message.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | ||||||
|  | package server | ||||||
|  | 
 | ||||||
|  | import "time" | ||||||
|  | 
 | ||||||
|  | // List of possible events | ||||||
|  | const ( | ||||||
|  | 	openEvent      = "open" | ||||||
|  | 	keepaliveEvent = "keepalive" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // message represents a message published to a topic | ||||||
|  | type message struct { | ||||||
|  | 	Time    int64  `json:"time"`            // Unix time in seconds | ||||||
|  | 	Event   string `json:"event,omitempty"` // One of the above | ||||||
|  | 	Message string `json:"message,omitempty"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // messageEncoder is a function that knows how to encode a message | ||||||
|  | type messageEncoder func(msg *message) (string, error) | ||||||
|  | 
 | ||||||
|  | // newMessage creates a new message with the current timestamp | ||||||
|  | func newMessage(event string, msg string) *message { | ||||||
|  | 	return &message{ | ||||||
|  | 		Time:    time.Now().Unix(), | ||||||
|  | 		Event:   event, | ||||||
|  | 		Message: msg, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // newOpenMessage is a convenience method to create an open message | ||||||
|  | func newOpenMessage() *message { | ||||||
|  | 	return newMessage(openEvent, "") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // newKeepaliveMessage is a convenience method to create a keepalive message | ||||||
|  | func newKeepaliveMessage() *message { | ||||||
|  | 	return newMessage(keepaliveEvent, "") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // newDefaultMessage is a convenience method to create a notification message | ||||||
|  | func newDefaultMessage(msg string) *message { | ||||||
|  | 	return newMessage("", msg) | ||||||
|  | } | ||||||
|  | @ -159,11 +159,7 @@ func (s *Server) handlePublishHTTP(w http.ResponseWriter, r *http.Request) error | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	msg := &message{ | 	if err := t.Publish(newDefaultMessage(string(b))); err != nil { | ||||||
| 		Time:    time.Now().UnixMilli(), |  | ||||||
| 		Message: string(b), |  | ||||||
| 	} |  | ||||||
| 	if err := t.Publish(msg); err != nil { |  | ||||||
| 		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 | ||||||
|  | @ -171,75 +167,74 @@ func (s *Server) handlePublishHTTP(w http.ResponseWriter, r *http.Request) error | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) error { | func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) error { | ||||||
| 	t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/json")) // Hack | 	encoder := func(msg *message) (string, error) { | ||||||
| 	subscriberID := t.Subscribe(func(msg *message) error { | 		var buf bytes.Buffer | ||||||
| 		if err := json.NewEncoder(w).Encode(&msg); err != nil { | 		if err := json.NewEncoder(&buf).Encode(&msg); err != nil { | ||||||
| 			return err | 			return "", err | ||||||
| 		} | 		} | ||||||
| 		if fl, ok := w.(http.Flusher); ok { | 		return buf.String(), nil | ||||||
| 			fl.Flush() |  | ||||||
| 	} | 	} | ||||||
| 		return nil | 	return s.handleSubscribe(w, r, "json", "application/stream+json", encoder) | ||||||
| 	}) |  | ||||||
| 	defer s.unsubscribe(t, subscriberID) |  | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests |  | ||||||
| 	select { |  | ||||||
| 	case <-t.ctx.Done(): |  | ||||||
| 	case <-r.Context().Done(): |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) error { | func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) error { | ||||||
| 	t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/sse")) // Hack | 	encoder := func(msg *message) (string, error) { | ||||||
| 	subscriberID := t.Subscribe(func(msg *message) error { |  | ||||||
| 		var buf bytes.Buffer | 		var buf bytes.Buffer | ||||||
| 		if err := json.NewEncoder(&buf).Encode(&msg); err != nil { | 		if err := json.NewEncoder(&buf).Encode(&msg); err != nil { | ||||||
| 			return err | 			return "", err | ||||||
| 		} | 		} | ||||||
| 		m := fmt.Sprintf("data: %s\n", buf.String()) | 		if msg.Event != "" { | ||||||
| 		if _, err := io.WriteString(w, m); err != nil { | 			return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this! | ||||||
| 			return err |  | ||||||
| 		} | 		} | ||||||
| 		if fl, ok := w.(http.Flusher); ok { | 		return fmt.Sprintf("data: %s\n", buf.String()), nil | ||||||
| 			fl.Flush() |  | ||||||
| 	} | 	} | ||||||
| 		return nil | 	return s.handleSubscribe(w, r, "sse", "text/event-stream", encoder) | ||||||
| 	}) |  | ||||||
| 	defer s.unsubscribe(t, subscriberID) |  | ||||||
| 	w.Header().Set("Content-Type", "text/event-stream") |  | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests |  | ||||||
| 	if _, err := io.WriteString(w, "event: open\n\n"); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if fl, ok := w.(http.Flusher); ok { |  | ||||||
| 		fl.Flush() |  | ||||||
| 	} |  | ||||||
| 	select { |  | ||||||
| 	case <-t.ctx.Done(): |  | ||||||
| 	case <-r.Context().Done(): |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request) error { | func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request) error { | ||||||
| 	t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/raw")) // Hack | 	encoder := func(msg *message) (string, error) { | ||||||
| 	subscriberID := t.Subscribe(func(msg *message) error { | 		if msg.Event == "" { // only handle default events | ||||||
| 		m := strings.ReplaceAll(msg.Message, "\n", " ") + "\n" | 			return strings.ReplaceAll(msg.Message, "\n", " ") + "\n", nil | ||||||
| 		if _, err := io.WriteString(w, m); err != nil { | 		} | ||||||
|  | 		return "\n", nil // "keepalive" and "open" events just send an empty line | ||||||
|  | 	} | ||||||
|  | 	return s.handleSubscribe(w, r, "raw", "text/plain", encoder) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, format string, contentType string, encoder messageEncoder) error { | ||||||
|  | 	t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/"+format)) // Hack | ||||||
|  | 	sub := func(msg *message) error { | ||||||
|  | 		m, err := encoder(msg) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		if _, err := w.Write([]byte(m)); err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		if fl, ok := w.(http.Flusher); ok { | 		if fl, ok := w.(http.Flusher); ok { | ||||||
| 			fl.Flush() | 			fl.Flush() | ||||||
| 		} | 		} | ||||||
| 		return nil | 		return nil | ||||||
| 	}) | 	} | ||||||
|  | 	subscriberID := t.Subscribe(sub) | ||||||
| 	defer s.unsubscribe(t, subscriberID) | 	defer s.unsubscribe(t, subscriberID) | ||||||
|  | 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests | ||||||
|  | 	w.Header().Set("Content-Type", contentType) | ||||||
|  | 	if err := sub(newOpenMessage()); err != nil { // Send out open message | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	for { | ||||||
| 		select { | 		select { | ||||||
| 		case <-t.ctx.Done(): | 		case <-t.ctx.Done(): | ||||||
| 	case <-r.Context().Done(): |  | ||||||
| 	} |  | ||||||
| 			return nil | 			return nil | ||||||
|  | 		case <-r.Context().Done(): | ||||||
|  | 			return nil | ||||||
|  | 		case <-time.After(s.config.KeepaliveInterval): | ||||||
|  | 			if err := sub(newKeepaliveMessage()); err != nil { // Send keepalive message | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error { | func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error { | ||||||
|  |  | ||||||
|  | @ -40,6 +40,10 @@ p { | ||||||
|     line-height: 140%; |     line-height: 140%; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | p.smallMarginBottom { | ||||||
|  |     margin-bottom: 10px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| tt { | tt { | ||||||
|     background: #eee; |     background: #eee; | ||||||
|     padding: 2px 7px; |     padding: 2px 7px; | ||||||
|  | @ -53,7 +57,7 @@ code { | ||||||
|     padding: 20px; |     padding: 20px; | ||||||
|     border-radius: 3px; |     border-radius: 3px; | ||||||
|     margin-top: 10px; |     margin-top: 10px; | ||||||
|     margin-bottom: 10px; |     margin-bottom: 20px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /* Lato font (OFL), https://fonts.google.com/specimen/Lato#about, | /* Lato font (OFL), https://fonts.google.com/specimen/Lato#about, | ||||||
|  |  | ||||||
|  | @ -60,7 +60,7 @@ const subscribeInternal = (topic, delaySec) => { | ||||||
|         eventSource.onmessage = (e) => { |         eventSource.onmessage = (e) => { | ||||||
|             const event = JSON.parse(e.data); |             const event = JSON.parse(e.data); | ||||||
|             notifySound.play(); |             notifySound.play(); | ||||||
|             new Notification(topic, { |             new Notification(`${location.host}/${topic}`, { | ||||||
|                 body: event.message, |                 body: event.message, | ||||||
|                 icon: '/static/img/favicon.png' |                 icon: '/static/img/favicon.png' | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|  | @ -21,15 +21,10 @@ type topic struct { | ||||||
| 	mu          sync.Mutex | 	mu          sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // message represents a message published to a topic |  | ||||||
| type message struct { |  | ||||||
| 	Time    int64  `json:"time"` |  | ||||||
| 	Message string `json:"message"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // subscriber is a function that is called for every new message on a topic | // subscriber is a function that is called for every new message on a topic | ||||||
| type subscriber func(msg *message) error | type subscriber func(msg *message) error | ||||||
| 
 | 
 | ||||||
|  | // newTopic creates a new topic | ||||||
| func newTopic(id string) *topic { | func newTopic(id string) *topic { | ||||||
| 	ctx, cancel := context.WithCancel(context.Background()) | 	ctx, cancel := context.WithCancel(context.Background()) | ||||||
| 	return &topic{ | 	return &topic{ | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue