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 @@ <!DOCTYPE html> <html lang="en"> <head> + <meta charset="UTF-8"> <title>ntfy.sh: EventSource Example</title> <style> body { font-size: 1.2em; line-height: 130%; } diff --git a/server/index.html b/server/index.html index 3a23585b..f86faa7f 100644 --- a/server/index.html +++ b/server/index.html @@ -1,19 +1,39 @@ <!DOCTYPE html> <html lang="en"> <head> - <title>ntfy.sh</title> - <style> - body { font-size: 1.2em; line-height: 130%; } - #error { color: darkred; font-style: italic; } - #main { max-width: 900px; margin: 0 auto 50px auto; } - </style> + <meta charset="UTF-8"> + + <title>ntfy.sh | simple HTTP-based pub-sub</title> + <link rel="stylesheet" href="static/css/app.css" type="text/css"> + + <!-- Mobile view --> + <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <meta name="HandheldFriendly" content="true"> + + <!-- Mobile browsers, background color --> + <meta name="theme-color" content="#004c79"> + <meta name="msapplication-navbutton-color" content="#004c79"> + <meta name="apple-mobile-web-app-status-bar-style" content="#004c79"> + + <!-- Favicon, see favicon.io --> + <link rel="icon" type="image/png" href="static/img/favicon.png"> + + <!-- Previews in Google, Slack, WhatsApp, etc. --> + <meta property="og:type" content="website" /> + <meta property="og:locale" content="en_US" /> + <meta property="og:site_name" content="ntfy.sh" /> + <meta property="og:title" content="ntfy.sh | simple HTTP-based pub-sub" /> + <meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." /> + <meta property="og:image" content="/static/img/ntfy.png" /> + <meta property="og:url" content="https://ntfy.sh" /> </head> <body> <div id="main"> <h1>ntfy.sh - simple HTTP-based pub-sub</h1> <p> - <b>ntfy</b> (pronounce: <i>notify</i>) is a simple <b>HTTP-based pub-sub notification service and tool</b>. - It allows you to send <b>desktop notifications via scripts</b>, entirely <b>without signup or cost</b>. + <b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based pub-sub notification service and tool. + It allows you to send <b>desktop notifications via scripts from any computer</b>, entirely <b>without signup or cost</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own. </p> <p id="error"></p> @@ -37,151 +57,31 @@ <p> <label for="topicField">Topic ID:</label> <input type="text" id="topicField" placeholder="Letters, numbers, _ and -" pattern="[-_A-Za-z]{1,64}" autofocus /> - <input type="submit" id="subscribeButton" value="Subscribe topic" /> + <input type="submit" id="subscribeButton" value="Subscribe" /> </p> </form> <p id="topicsHeader">Subscribed topics:</p> <ul id="topicsList"></ul> <h3>Subscribe via your app, or via the CLI</h3> - <tt> + <code> curl -s ntfy.sh/mytopic/raw # one message per line (\n are replaced with a space)<br/> curl -s ntfy.sh/mytopic/json # one JSON message per line<br/> curl -s ntfy.sh/mytopic/sse # server-sent events (SSE) stream - </tt> + </code> - <h3>Publishing messages</h3> + <h2>Publishing messages</h2> <p> Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>: </p> - <tt> + <code> curl -d "long process is done" ntfy.sh/mytopic - </tt> + </code> <p> 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. </p> </div> - -<script type="text/javascript"> - 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} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; - topicsList.appendChild(topicEntry); - } - topicsHeader.style.display = ''; - - // Open event source - let eventSource = new EventSource(`${topic}/sse`); - eventSource.onopen = () => { - topicEntry.innerHTML = `${topic} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; - delaySec = 0; // Reset on successful connection - }; - eventSource.onerror = (e) => { - const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15; - topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button disabled="disabled">Test</button> <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 = (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'; - } -</script> - +<script src="static/js/app.js"></script> </body> </html> 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} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; + topicsList.appendChild(topicEntry); + } + topicsHeader.style.display = ''; + + // Open event source + let eventSource = new EventSource(`${topic}/sse`); + eventSource.onopen = () => { + topicEntry.innerHTML = `${topic} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; + delaySec = 0; // Reset on successful connection + }; + eventSource.onerror = (e) => { + const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15; + topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button disabled="disabled">Test</button> <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 = (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'; +}