- ntfy (pronounce: notify) is a simple HTTP-based pub-sub notification service and tool.
- It allows you to send desktop notifications via scripts, entirely without signup or cost.
+ ntfy (pronounce: notify) is a simple HTTP-based pub-sub notification service and tool.
+ It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost.
It's also open source if you want to run your own.
@@ -37,151 +57,31 @@
-
+
Subscribed topics:
Subscribe via your app, or via the CLI
-
+
curl -s ntfy.sh/mytopic/raw # one message per line (\n are replaced with a space)
curl -s ntfy.sh/mytopic/json # one JSON message per line
curl -s ntfy.sh/mytopic/sse # server-sent events (SSE) stream
-
+
-
Publishing messages
+
Publishing 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
-
+
Messages published to a non-existing topic or a topic without subscribers will not be delivered later.
There is (currently) no buffering of any kind. If you're not listening, the message won't be delivered.
-
-
-
+
diff --git a/server/server.go b/server/server.go
index a8050f47..acb1664a 100644
--- a/server/server.go
+++ b/server/server.go
@@ -2,6 +2,7 @@ package server
import (
"bytes"
+ "embed"
_ "embed" // required for go:embed
"encoding/json"
"fmt"
@@ -51,10 +52,14 @@ var (
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`)
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`)
+ staticRegex = regexp.MustCompile(`^/static/.+`)
//go:embed "index.html"
indexSource string
+ //go:embed static
+ webStaticFs embed.FS
+
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
)
@@ -123,6 +128,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
}
if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.handleHome(w, r)
+ } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
+ return s.handleStatic(w, r)
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
return s.handleSubscribeJSON(w, r)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
@@ -241,6 +248,11 @@ func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
return nil
}
+func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
+ http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
+ return nil
+}
+
func (s *Server) createTopic(id string) *topic {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/server/static/css/app.css b/server/static/css/app.css
new file mode 100644
index 00000000..1c623901
--- /dev/null
+++ b/server/static/css/app.css
@@ -0,0 +1,76 @@
+/* general styling */
+
+html, body {
+ font-family: 'Lato', sans-serif;
+ color: #333;
+ font-size: 1.1em;
+}
+
+a {
+ color: #39005a;
+}
+
+a:hover {
+ text-decoration: none;
+}
+
+h1 {
+ margin-top: 25px;
+ margin-bottom: 18px;
+ font-size: 2.5em;
+}
+
+h2 {
+ margin-top: 20px;
+ margin-bottom: 5px;
+ font-size: 1.8em;
+}
+
+h3 {
+ margin-top: 20px;
+ margin-bottom: 5px;
+ font-size: 1.3em;
+}
+
+p {
+ margin-top: 0;
+ font-size: 1.1em;
+}
+
+tt {
+ background: #eee;
+ padding: 2px 7px;
+ border-radius: 3px;
+}
+
+code {
+ display: block;
+ background: #eee;
+ font-family: monospace;
+ padding: 20px;
+ border-radius: 3px;
+}
+
+/* Lato font (OFL), https://fonts.google.com/specimen/Lato#about,
+ embedded with the help of https://google-webfonts-helper.herokuapp.com/fonts/lato?subsets=latin */
+
+@font-face {
+ font-family: 'Lato';
+ font-style: normal;
+ font-weight: 400;
+ src: local(''),
+ url('../font/lato-v17-latin-ext_latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+ url('../font/lato-v17-latin-ext_latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+
+/* Main page */
+
+#main {
+ max-width: 900px;
+ margin: 0 auto 50px auto;
+}
+
+#error {
+ color: darkred;
+ font-style: italic;
+}
diff --git a/server/static/font/lato-v17-latin-ext_latin-regular.woff b/server/static/font/lato-v17-latin-ext_latin-regular.woff
new file mode 100644
index 00000000..c6d3d1d9
Binary files /dev/null and b/server/static/font/lato-v17-latin-ext_latin-regular.woff differ
diff --git a/server/static/font/lato-v17-latin-ext_latin-regular.woff2 b/server/static/font/lato-v17-latin-ext_latin-regular.woff2
new file mode 100644
index 00000000..4153a825
Binary files /dev/null and b/server/static/font/lato-v17-latin-ext_latin-regular.woff2 differ
diff --git a/server/static/img/favicon.png b/server/static/img/favicon.png
new file mode 100644
index 00000000..93982c6f
Binary files /dev/null and b/server/static/img/favicon.png differ
diff --git a/server/static/img/ntfy.png b/server/static/img/ntfy.png
new file mode 100644
index 00000000..93982c6f
Binary files /dev/null and b/server/static/img/ntfy.png differ
diff --git a/server/static/js/app.js b/server/static/js/app.js
new file mode 100644
index 00000000..df459ca0
--- /dev/null
+++ b/server/static/js/app.js
@@ -0,0 +1,128 @@
+
+/**
+ * Hello, dear curious visitor. I am not a web-guy, so please don't judge my horrible JS code.
+ * In fact, please do tell me about all the things I did wrong and that I could improve. I've been trying
+ * to read up on modern JS, but it's just a little much.
+ *
+ * Feel free to open tickets at https://github.com/binwiederhier/ntfy/issues. Thank you!
+ */
+
+/* All the things */
+
+let topics = {};
+
+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");
+
+const subscribe = (topic) => {
+ if (Notification.permission !== "granted") {
+ Notification.requestPermission().then((permission) => {
+ if (permission === "granted") {
+ subscribeInternal(topic, 0);
+ } else {
+ showNotificationDeniedError();
+ }
+ });
+ } else {
+ subscribeInternal(topic, 0);
+ }
+};
+
+const subscribeInternal = (topic, delaySec) => {
+ setTimeout(() => {
+ // Render list entry
+ let topicEntry = document.getElementById(`topic-${topic}`);
+ if (!topicEntry) {
+ topicEntry = document.createElement('li');
+ topicEntry.id = `topic-${topic}`;
+ topicEntry.innerHTML = `${topic} `;
+ topicsList.appendChild(topicEntry);
+ }
+ topicsHeader.style.display = '';
+
+ // Open event source
+ let eventSource = new EventSource(`${topic}/sse`);
+ eventSource.onopen = () => {
+ topicEntry.innerHTML = `${topic} `;
+ delaySec = 0; // Reset on successful connection
+ };
+ eventSource.onerror = (e) => {
+ const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
+ topicEntry.innerHTML = `${topic} (Reconnecting in ${newDelaySec}s ...) `;
+ 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 = (topic) => {
+ topics[topic].close();
+ 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 test = (topic) => {
+ fetch(`/${topic}`, {
+ method: 'PUT',
+ body: `This is a test notification for topic ${topic}!`
+ })
+};
+
+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 () {
+ if (!topicField.value) {
+ return false;
+ }
+ subscribe(topicField.value);
+ topicField.value = "";
+ return false;
+};
+
+// Disable Web UI if notifications of EventSource are not available
+if (!window["Notification"] || !window["EventSource"]) {
+ showBrowserIncompatibleError();
+} else if (Notification.permission === "denied") {
+ showNotificationDeniedError();
+}
+
+// Reset UI
+topicField.value = "";
+
+// Restore topics
+const storedTopics = localStorage.getItem('topics');
+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';
+}