Makefile, Dockerfile, GoReleaser, config.yml, systemd service

This commit is contained in:
Philipp Heckel 2021-10-23 21:29:45 -04:00
parent a66bd6dad7
commit e1c9fef6dc
16 changed files with 512 additions and 68 deletions

View file

@ -3,37 +3,45 @@
<head>
<title>ntfy.sh</title>
<style>
body { font-size: 1.3em; line-height: 140%; }
#error { color: darkred; font-style: italic; }
#main { max-width: 800px; margin: 0 auto; }
</style>
</head>
<body>
<h1>ntfy.sh</h1>
<div id="main">
<h1>ntfy.sh</h1>
<p>
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. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>.
</p>
<p>
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based 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. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p id="error"></p>
<p id="error"></p>
<form id="subscribeForm">
<input type="text" id="topicField" size="64" autofocus />
<input type="submit" id="subscribeButton" value="Subscribe topic" />
</form>
<form id="subscribeForm">
<p>
<input type="text" id="topicField" size="64" placeholder="Topic ID (letters, numbers, _ and -)" pattern="[-_A-Za-z]{1,64}" autofocus />
<input type="submit" id="subscribeButton" value="Subscribe topic" />
</p>
</form>
<p>Topics:</p>
<ul id="topicsList">
</ul>
<p id="topicsHeader"><b>Subscribed topics:</b></p>
<ul id="topicsList"></ul>
</div>
<script type="text/javascript">
let topics = {};
const topicField = document.getElementById("topicField");
const topicsHeader = document.getElementById("topicsHeader");
const topicsList = document.getElementById("topicsList");
const topicField = document.getElementById("topicField");
const subscribeButton = document.getElementById("subscribeButton");
const subscribeForm = document.getElementById("subscribeForm");
const errorField = document.getElementById("error");
@ -43,6 +51,8 @@
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
subscribeInternal(topic, 0);
} else {
showNotificationDeniedError();
}
});
} else {
@ -60,6 +70,7 @@
topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
topicsList.appendChild(topicEntry);
}
topicsHeader.style.display = '';
// Open event source
let eventSource = new EventSource(`${topic}/sse`);
@ -68,7 +79,6 @@
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()
@ -88,6 +98,23 @@
delete topics[topic];
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
document.getElementById(`topic-${topic}`).remove();
if (Object.keys(topics).length === 0) {
topicsHeader.style.display = 'none';
}
};
const showError = (msg) => {
errorField.innerHTML = msg;
topicField.disabled = true;
subscribeButton.disabled = true;
};
const showBrowserIncompatibleError = () => {
showError("Your browser is not compatible to use the web-based desktop notifications.");
};
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 () {
@ -101,13 +128,9 @@
// Disable Web UI if notifications of EventSource are not available
if (!window["Notification"] || !window["EventSource"]) {
errorField.innerHTML = "Your browser is not compatible to use the web-based desktop notifications.";
topicField.disabled = true;
subscribeButton.disabled = true;
showBrowserIncompatibleError();
} else if (Notification.permission === "denied") {
errorField.innerHTML = "You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.";
topicField.disabled = true;
subscribeButton.disabled = true;
showNotificationDeniedError();
}
// Reset UI
@ -115,10 +138,14 @@
// Restore topics
const storedTopics = localStorage.getItem('topics');
if (storedTopics) {
JSON.parse(storedTopics).forEach((topic) => {
subscribeInternal(topic, 0);
});
if (storedTopics && Notification.permission === "granted") {
const storedTopicsArray = JSON.parse(storedTopics)
storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); });
if (storedTopicsArray.length === 0) {
topicsHeader.style.display = 'none';
}
} else {
topicsHeader.style.display = 'none';
}
</script>

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/config"
"io"
"log"
"net/http"
@ -16,6 +17,7 @@ import (
)
type Server struct {
config *config.Config
topics map[string]*topic
mu sync.Mutex
}
@ -33,13 +35,17 @@ var (
topicRegex = regexp.MustCompile(`^/[^/]+$`)
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`)
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`)
//go:embed "index.html"
indexSource string
errTopicNotFound = errors.New("topic not found")
)
func New() *Server {
func New(conf *config.Config) *Server {
return &Server{
config: conf,
topics: make(map[string]*topic),
}
}
@ -50,23 +56,22 @@ func (s *Server) Run() error {
}
func (s *Server) listenAndServe() error {
log.Printf("Listening on :9997")
log.Printf("Listening on %s", s.config.ListenHTTP)
http.HandleFunc("/", s.handle)
return http.ListenAndServe(":9997", nil)
return http.ListenAndServe(s.config.ListenHTTP, nil)
}
func (s *Server) runMonitor() {
for {
time.Sleep(5 * time.Second)
time.Sleep(30 * time.Second)
s.mu.Lock()
log.Printf("topics: %d", len(s.topics))
var subscribers, messages int
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()
subs, msgs := t.Stats()
subscribers += subs
messages += msgs
}
// TODO kill dead topics
log.Printf("Stats: %d topic(s), %d subscriber(s), %d message(s) sent", len(s.topics), subscribers, messages)
s.mu.Unlock()
}
}
@ -74,7 +79,7 @@ func (s *Server) runMonitor() {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
if err := s.handleInternal(w, r); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, err.Error())
_, _ = io.WriteString(w, err.Error()+"\n")
}
}
@ -85,6 +90,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
return s.handleSubscribeJSON(w, r)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
return s.handleSubscribeSSE(w, r)
} else if r.Method == http.MethodGet && rawRegex.MatchString(r.URL.Path) {
return s.handleSubscribeRaw(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
return s.handlePublishHTTP(w, r)
}
@ -125,7 +132,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) err
}
return nil
})
defer t.Unsubscribe(subscriberID)
defer s.unsubscribe(t, subscriberID)
select {
case <-t.ctx.Done():
case <-r.Context().Done():
@ -149,7 +156,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro
}
return nil
})
defer t.Unsubscribe(subscriberID)
defer s.unsubscribe(t, subscriberID)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, "event: open\n\n"); err != nil {
@ -165,6 +172,26 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro
return nil
}
func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request) error {
t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/raw")) // Hack
subscriberID := t.Subscribe(func(msg *message) error {
m := strings.ReplaceAll(msg.Message, "\n", " ") + "\n"
if _, err := io.WriteString(w, m); err != nil {
return err
}
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
return nil
})
defer s.unsubscribe(t, subscriberID)
select {
case <-t.ctx.Done():
case <-r.Context().Done():
}
return nil
}
func (s *Server) createTopic(id string) *topic {
s.mu.Lock()
defer s.mu.Unlock()
@ -179,7 +206,15 @@ func (s *Server) topic(topicID string) (*topic, error) {
defer s.mu.Unlock()
c, ok := s.topics[topicID]
if !ok {
return nil, errors.New("topic does not exist")
return nil, errTopicNotFound
}
return c, nil
}
func (s *Server) unsubscribe(t *topic, subscriberID int) {
s.mu.Lock()
defer s.mu.Unlock()
if subscribers := t.Unsubscribe(subscriberID); subscribers == 0 {
delete(s.topics, t.id)
}
}

View file

@ -41,10 +41,11 @@ func (t *topic) Subscribe(s subscriber) int {
return subscriberID
}
func (t *topic) Unsubscribe(id int) {
func (t *topic) Unsubscribe(id int) int {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.subscribers, id)
return len(t.subscribers)
}
func (t *topic) Publish(m *message) error {
@ -57,12 +58,18 @@ func (t *topic) Publish(m *message) error {
t.messages++
for _, s := range t.subscribers {
if err := s(m); err != nil {
log.Printf("error publishing message to subscriber x")
log.Printf("error publishing message to subscriber")
}
}
return nil
}
func (t *topic) Stats() (subscribers int, messages int) {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.subscribers), t.messages
}
func (t *topic) Close() {
t.cancel()
}