This commit is contained in:
Philipp Heckel 2021-10-24 14:22:53 -04:00
parent 39574c954b
commit 317621c696
10 changed files with 252 additions and 135 deletions

BIN
assets/favicon.xcf Normal file

Binary file not shown.

View File

@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<title>ntfy.sh: EventSource Example</title> <title>ntfy.sh: EventSource Example</title>
<style> <style>
body { font-size: 1.2em; line-height: 130%; } body { font-size: 1.2em; line-height: 130%; }

View File

@ -1,19 +1,39 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>ntfy.sh</title> <meta charset="UTF-8">
<style>
body { font-size: 1.2em; line-height: 130%; } <title>ntfy.sh | simple HTTP-based pub-sub</title>
#error { color: darkred; font-style: italic; } <link rel="stylesheet" href="static/css/app.css" type="text/css">
#main { max-width: 900px; margin: 0 auto 50px auto; }
</style> <!-- 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> </head>
<body> <body>
<div id="main"> <div id="main">
<h1>ntfy.sh - simple HTTP-based pub-sub</h1> <h1>ntfy.sh - simple HTTP-based pub-sub</h1>
<p> <p>
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple <b>HTTP-based pub-sub notification service and tool</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</b>, entirely <b>without signup or cost</b>. 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. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
</p> </p>
<p id="error"></p> <p id="error"></p>
@ -37,151 +57,31 @@
<p> <p>
<label for="topicField">Topic ID:</label> <label for="topicField">Topic ID:</label>
<input type="text" id="topicField" placeholder="Letters, numbers, _ and -" pattern="[-_A-Za-z]{1,64}" autofocus /> <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> </p>
</form> </form>
<p id="topicsHeader">Subscribed topics:</p> <p id="topicsHeader">Subscribed topics:</p>
<ul id="topicsList"></ul> <ul id="topicsList"></ul>
<h3>Subscribe via your app, or via the CLI</h3> <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/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/json # one JSON message per line<br/>
curl -s ntfy.sh/mytopic/sse # server-sent events (SSE) stream curl -s ntfy.sh/mytopic/sse # server-sent events (SSE) stream
</tt> </code>
<h3>Publishing messages</h3> <h2>Publishing messages</h2>
<p> <p>
Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>: Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>:
</p> </p>
<tt> <code>
curl -d "long process is done" ntfy.sh/mytopic curl -d "long process is done" ntfy.sh/mytopic
</tt> </code>
<p> <p>
Messages published to a non-existing topic or a topic without subscribers will not be delivered later. 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. There is (currently) no buffering of any kind. If you're not listening, the message won't be delivered.
</p> </p>
</div> </div>
<script src="static/js/app.js"></script>
<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>
</body> </body>
</html> </html>

View File

@ -2,6 +2,7 @@ package server
import ( import (
"bytes" "bytes"
"embed"
_ "embed" // required for go:embed _ "embed" // required for go:embed
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -51,10 +52,14 @@ var (
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`) jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`) sseRegex = regexp.MustCompile(`^/[^/]+/sse$`)
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`) rawRegex = regexp.MustCompile(`^/[^/]+/raw$`)
staticRegex = regexp.MustCompile(`^/static/.+`)
//go:embed "index.html" //go:embed "index.html"
indexSource string indexSource string
//go:embed static
webStaticFs embed.FS
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)} errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)} 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 == "/" { if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.handleHome(w, r) 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) { } 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) {
@ -241,6 +248,11 @@ func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
return nil 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 { func (s *Server) createTopic(id string) *topic {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

76
server/static/css/app.css Normal file
View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
server/static/img/ntfy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

128
server/static/js/app.js Normal file
View File

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