Remove websockets, readme, better UI

This commit is contained in:
Philipp Heckel 2021-10-23 15:22:17 -04:00
parent 630ecd351f
commit a66bd6dad7
4 changed files with 133 additions and 100 deletions

View file

@ -1,10 +1,44 @@
# ntfy
ntfy is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications
via scripts. I run a free version of it on *[ntfy.sh](https://ntfy.sh)*. No signups or cost.
echo "mychan:long process is done" | nc -N ntfy.sh 9999 ## Usage
curl -d "long process is done" ntfy.sh/mychan
publish on channel
curl ntfy.sh/mychan ### Subscribe to a topic
subscribe to channel
ntfy.sh/mychan/ws You can subscribe to a topic either in a web UI, or in your own app by subscribing to an SSE/EventSource
or JSON feed.
Here's how to do it via curl see the SSE stream in `curl`:
```
curl -s localhost:9997/mytopic/sse
```
You can easily script it to execute any command when a message arrives:
```
while read json; do
msg="$(echo "$json" | jq -r .message)"
notify-send "$msg"
done < <(stdbuf -i0 -o0 curl -s localhost:9997/mytopic/json)
```
### Publish messages
Publishing messages can be done via PUT or POST using. Here's an example using `curl`:
```
curl -d "long process is done" ntfy.sh/mytopic
```
## TODO
- /raw endpoint
- netcat usage
- rate limiting / abuse protection
- release/packaging
## Contributing
I welcome any and all contributions. Just create a PR or an issue.
## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).

View file

@ -10,22 +10,22 @@
<p> <p>
ntfy.sh is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications ntfy.sh is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications
via scripts, without signup or cost. It's entirely free and open source. via scripts, without signup or cost. It's entirely free and open source. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>.
</p> </p>
<p> <p>
<b>Usage:</b> You can subscribe to a topic either in this web UI, or in your own app by subscribing to an SSE/EventSource You can subscribe to a topic either in this web UI, or in your own app by subscribing to an SSE/EventSource
or JSON feed. Once subscribed, you can publish messages via PUT or POST. or JSON feed. Once subscribed, you can publish messages via PUT or POST.
</p> </p>
<div id="error"></div> <p id="error"></p>
<form id="subscribeForm"> <form id="subscribeForm">
<input type="text" id="topicField" size="64" autofocus /> <input type="text" id="topicField" size="64" autofocus />
<input type="submit" id="subscribeButton" value="Subscribe topic" /> <input type="submit" id="subscribeButton" value="Subscribe topic" />
</form> </form>
Topics: <p>Topics:</p>
<ul id="topicsList"> <ul id="topicsList">
</ul> </ul>
@ -38,50 +38,68 @@ Topics:
const subscribeForm = document.getElementById("subscribeForm"); const subscribeForm = document.getElementById("subscribeForm");
const errorField = document.getElementById("error"); const errorField = document.getElementById("error");
const subscribe = function (topic) { const subscribe = (topic) => {
if (Notification.permission !== "granted") { if (Notification.permission !== "granted") {
Notification.requestPermission().then(function (permission) { Notification.requestPermission().then((permission) => {
if (permission === "granted") { if (permission === "granted") {
subscribeInternal(topic); subscribeInternal(topic, 0);
} }
}); });
} else { } else {
subscribeInternal(topic); subscribeInternal(topic, 0);
} }
}; };
const subscribeInternal = function (topic) { const subscribeInternal = (topic, delaySec) => {
let eventSource = new EventSource(`${topic}/sse`); setTimeout(() => {
eventSource.onerror = function (e) { // Render list entry
console.log(e); let topicEntry = document.getElementById(`topic-${topic}`);
errorField.innerHTML = "Error " + e; if (!topicEntry) {
}; topicEntry = document.createElement('li');
eventSource.onmessage = function (e) { topicEntry.id = `topic-${topic}`;
const event = JSON.parse(e.data); topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
new Notification(event.message); topicsList.appendChild(topicEntry);
}; }
topics[topic] = eventSource;
let topicEntry = document.createElement('li'); // Open event source
topicEntry.id = `topic-${topic}`; let eventSource = new EventSource(`${topic}/sse`);
topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; eventSource.onopen = () => {
topicsList.appendChild(topicEntry); topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
delaySec = 0; // Reset on successful connection
};
eventSource.onerror = (e) => {
console.log("onerror")
const newDelaySec = (delaySec + 5 <= 30) ? delaySec + 5 : 30;
topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
eventSource.close()
subscribeInternal(topic, newDelaySec);
};
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
new Notification(event.message);
};
topics[topic] = eventSource;
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
}, delaySec * 1000);
}; };
const unsubscribe = function(topic) { const unsubscribe = (topic) => {
topics[topic].close(); topics[topic].close();
delete topics[topic];
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
document.getElementById(`topic-${topic}`).remove(); document.getElementById(`topic-${topic}`).remove();
}; };
subscribeForm.onsubmit = function () { subscribeForm.onsubmit = function () {
alert("hi")
if (!topicField.value) { if (!topicField.value) {
return false; return false;
} }
subscribe(topicField.value); subscribe(topicField.value);
topicField.value = "";
return false; return false;
}; };
// Disable Web UI if notifications of EventSource are not available
if (!window["Notification"] || !window["EventSource"]) { if (!window["Notification"] || !window["EventSource"]) {
errorField.innerHTML = "Your browser is not compatible to use the web-based desktop notifications."; errorField.innerHTML = "Your browser is not compatible to use the web-based desktop notifications.";
topicField.disabled = true; topicField.disabled = true;
@ -91,6 +109,17 @@ Topics:
topicField.disabled = true; topicField.disabled = true;
subscribeButton.disabled = true; subscribeButton.disabled = true;
} }
// Reset UI
topicField.value = "";
// Restore topics
const storedTopics = localStorage.getItem('topics');
if (storedTopics) {
JSON.parse(storedTopics).forEach((topic) => {
subscribeInternal(topic, 0);
});
}
</script> </script>
</body> </body>

View file

@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gorilla/websocket"
"io" "io"
"log" "log"
"net/http" "net/http"
@ -18,11 +17,11 @@ import (
type Server struct { type Server struct {
topics map[string]*topic topics map[string]*topic
mu sync.Mutex mu sync.Mutex
} }
type message struct { type message struct {
Time int64 `json:"time"` Time int64 `json:"time"`
Message string `json:"message"` Message string `json:"message"`
} }
@ -31,14 +30,9 @@ 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$`)
wsRegex = regexp.MustCompile(`^/[^/]+/ws$`)
wsUpgrader = websocket.Upgrader{
ReadBufferSize: messageLimit,
WriteBufferSize: messageLimit,
}
//go:embed "index.html" //go:embed "index.html"
indexSource string indexSource string
@ -51,26 +45,32 @@ func New() *Server {
} }
func (s *Server) Run() error { func (s *Server) Run() error {
go func() { go s.runMonitor()
for { return s.listenAndServe()
time.Sleep(5 * time.Second) }
s.mu.Lock()
log.Printf("topics: %d", len(s.topics)) func (s *Server) listenAndServe() error {
for _, t := range s.topics {
t.mu.Lock()
log.Printf("- %s: %d subscriber(s), %d message(s) sent, last active = %s",
t.id, len(t.subscribers), t.messages, t.last.String())
t.mu.Unlock()
}
// TODO kill dead topics
s.mu.Unlock()
}
}()
log.Printf("Listening on :9997") log.Printf("Listening on :9997")
http.HandleFunc("/", s.handle) http.HandleFunc("/", s.handle)
return http.ListenAndServe(":9997", nil) return http.ListenAndServe(":9997", nil)
} }
func (s *Server) runMonitor() {
for {
time.Sleep(5 * time.Second)
s.mu.Lock()
log.Printf("topics: %d", len(s.topics))
for _, t := range s.topics {
t.mu.Lock()
log.Printf("- %s: %d subscriber(s), %d message(s) sent, last active = %s",
t.id, len(t.subscribers), t.messages, t.last.String())
t.mu.Unlock()
}
// TODO kill dead topics
s.mu.Unlock()
}
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
if err := s.handleInternal(w, r); err != nil { if err := s.handleInternal(w, r); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -81,8 +81,6 @@ 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 {
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.MethodGet && wsRegex.MatchString(r.URL.Path) {
return s.handleSubscribeWS(w, r)
} 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) return s.handleSubscribeJSON(w, r)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
@ -118,7 +116,7 @@ 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 t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/json")) // Hack
subscriberID := t.Subscribe(func (msg *message) error { subscriberID := t.Subscribe(func(msg *message) error {
if err := json.NewEncoder(w).Encode(&msg); err != nil { if err := json.NewEncoder(w).Encode(&msg); err != nil {
return err return err
} }
@ -137,12 +135,12 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) err
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 t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/sse")) // Hack
subscriberID := t.Subscribe(func (msg *message) 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\n", buf.String()) m := fmt.Sprintf("data: %s\n", buf.String())
if _, err := io.WriteString(w, m); err != nil { if _, err := io.WriteString(w, m); err != nil {
return err return err
} }
@ -154,6 +152,12 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro
defer t.Unsubscribe(subscriberID) defer t.Unsubscribe(subscriberID)
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, "event: open\n\n"); err != nil {
return err
}
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
select { select {
case <-t.ctx.Done(): case <-t.ctx.Done():
case <-r.Context().Done(): case <-r.Context().Done():
@ -161,40 +165,6 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro
return nil return nil
} }
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request) error {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/ws")) // Hack
t.Subscribe(func (msg *message) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
return err
}
defer conn.Close()
/*conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}*/
w, err := conn.NextWriter(websocket.TextMessage)
if err != nil {
return err
}
if _, err := w.Write([]byte(msg.Message)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return nil
})
return nil
}
func (s *Server) createTopic(id string) *topic { func (s *Server) createTopic(id string) *topic {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View file

@ -12,11 +12,11 @@ import (
type topic struct { type topic struct {
id string id string
subscribers map[int]subscriber subscribers map[int]subscriber
messages int messages int
last time.Time last time.Time
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
mu sync.Mutex mu sync.Mutex
} }
type subscriber func(msg *message) error type subscriber func(msg *message) error