diff --git a/assets/favicon.xcf b/assets/favicon.xcf new file mode 100644 index 00000000..9f0bc699 Binary files /dev/null and b/assets/favicon.xcf differ diff --git a/examples/example_eventsource_sse.html b/examples/example_eventsource_sse.html index 83a8344a..db1acdae 100644 --- a/examples/example_eventsource_sse.html +++ b/examples/example_eventsource_sse.html @@ -1,6 +1,7 @@ + ntfy.sh: EventSource Example + + + ntfy.sh | simple HTTP-based pub-sub + + + + + + + + + + + + + + + + + + + + + + +

ntfy.sh - simple HTTP-based pub-sub

- 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'; +}