Subscribe endpoint consolidation; same behavior for all endpoints; keepalive
parent
b72afb1695
commit
a38aca47bd
|
@ -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)
|
||||||
|
|
|
@ -8,8 +8,9 @@ import (
|
||||||
|
|
||||||
// Defines default config settings
|
// Defines default config settings
|
||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
defaultManagerInterval = time.Minute
|
DefaultKeepaliveInterval = 30 * time.Second
|
||||||
|
defaultManagerInterval = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines the max number of requests, here:
|
// Defines the max number of requests, here:
|
||||||
|
@ -21,18 +22,20 @@ var (
|
||||||
|
|
||||||
// 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
|
||||||
Limit rate.Limit
|
Limit rate.Limit
|
||||||
LimitBurst int
|
LimitBurst int
|
||||||
ManagerInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
|
ManagerInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
Limit: defaultLimit,
|
Limit: defaultLimit,
|
||||||
LimitBurst: defaultLimitBurst,
|
LimitBurst: defaultLimitBurst,
|
||||||
ManagerInterval: defaultManagerInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
|
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>
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
113
server/server.go
113
server/server.go
|
@ -48,11 +48,11 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
topicRegex = regexp.MustCompile(`^/[^/]+$`)
|
topicRegex = regexp.MustCompile(`^/[^/]+$`)
|
||||||
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
|
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
|
||||||
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`)
|
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`)
|
||||||
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`)
|
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`)
|
||||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
|
|
||||||
//go:embed "index.html"
|
//go:embed "index.html"
|
||||||
indexSource string
|
indexSource string
|
||||||
|
@ -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
|
|
||||||
})
|
|
||||||
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
|
return s.handleSubscribe(w, r, "json", "application/stream+json", encoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
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 {
|
return s.handleSubscribe(w, r, "sse", "text/event-stream", encoder)
|
||||||
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
|
||||||
})
|
|
||||||
defer s.unsubscribe(t, subscriberID)
|
|
||||||
select {
|
|
||||||
case <-t.ctx.Done():
|
|
||||||
case <-r.Context().Done():
|
|
||||||
}
|
}
|
||||||
return nil
|
subscriberID := t.Subscribe(sub)
|
||||||
|
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 {
|
||||||
|
case <-t.ctx.Done():
|
||||||
|
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…
Reference in New Issue