diff --git a/server/index.html b/server/index.gohtml similarity index 85% rename from server/index.html rename to server/index.gohtml index ac650e1..422f250 100644 --- a/server/index.html +++ b/server/index.gohtml @@ -1,3 +1,4 @@ +{{- /*gotype: heckel.io/ntfy/server.indexPage*/ -}} @@ -27,12 +28,16 @@ +{{if .Topic}} + + +{{end}} -
-

ntfy
ntfy.sh - simple HTTP-based pub-sub

+ + diff --git a/server/server.go b/server/server.go index 526a668..27f0eb8 100644 --- a/server/server.go +++ b/server/server.go @@ -11,6 +11,8 @@ import ( "fmt" "google.golang.org/api/option" "heckel.io/ntfy/config" + "heckel.io/ntfy/util" + "html/template" "io" "log" "net" @@ -46,20 +48,26 @@ func (e errHTTP) Error() string { return fmt.Sprintf("http: %s", e.Status) } +type indexPage struct { + Topic string + CacheDuration string +} + const ( messageLimit = 512 ) var ( - topicRegex = regexp.MustCompile(`^/[^/]+$`) - jsonRegex = regexp.MustCompile(`^/[^/]+/json$`) - sseRegex = regexp.MustCompile(`^/[^/]+/sse$`) - rawRegex = regexp.MustCompile(`^/[^/]+/raw$`) + topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! + jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/json$`) + sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/sse$`) + rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/raw$`) staticRegex = regexp.MustCompile(`^/static/.+`) - //go:embed "index.html" - indexSource string + //go:embed "index.gohtml" + indexSource string + indexTemplate = template.Must(template.New("index").Parse(indexSource)) //go:embed static webStaticFs embed.FS @@ -159,7 +167,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { } 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 == "/" || topicRegex.MatchString(r.URL.Path)) { return s.handleHome(w, r) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return s.handleEmpty(w, r) @@ -180,8 +188,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { - _, err := io.WriteString(w, indexSource) - return err + return indexTemplate.Execute(w, &indexPage{ + Topic: r.URL.Path[1:], + CacheDuration: util.DurationToHuman(s.config.CacheDuration), + }) } func (s *Server) handleEmpty(w http.ResponseWriter, r *http.Request) error { diff --git a/server/static/css/app.css b/server/static/css/app.css index 709d8eb..c1aa89d 100644 --- a/server/static/css/app.css +++ b/server/static/css/app.css @@ -6,6 +6,12 @@ html, body { font-size: 1.1em; } +html { + /* prevent scrollbar from repositioning website: + * https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */ + overflow-y: scroll; +} + a, a:visited { color: #3a9784; } @@ -107,7 +113,7 @@ button:hover { ul { padding-left: 1em; - list-style-type: none; + list-style-type: circle; padding-bottom: 0; margin: 0; } @@ -146,7 +152,6 @@ li { #subscribeBox ul { margin: 0; - padding: 0; } #subscribeBox li { @@ -160,6 +165,10 @@ li { vertical-align: bottom; } + #subscribeBox li a { + padding: 0 5px 0 0; + } + #subscribeBox button { font-size: 0.8em; background: #3a9784; @@ -202,7 +211,6 @@ li { #subscribeBox ul { margin: 0; - padding: 0; } #subscribeBox input { @@ -228,6 +236,10 @@ li { vertical-align: bottom; } + #subscribeBox li a { + padding: 0 5px 0 0; + } + #subscribeBox button { font-size: 0.7em; background: #3a9784; @@ -240,7 +252,62 @@ li { #subscribeBox button:hover { background: #317f6f; } - } +/** Detail view */ +#detail { + display: none; + position: absolute; + z-index: 1; + left: 8px; + right: 8px; + top: 0; + bottom: 0; + background: white; +} +#detail .detailDate { + color: #888; + font-size: 0.9em; +} + +#detail .detailMessage { + margin-bottom: 20px; + font-size: 1.1em; +} + +#detail #detailMain { + max-width: 900px; + margin: 0 auto 50px auto; + position: relative; /* required for close button's "position: absolute" */ +} + +#detail #detailCloseButton { + background: #eee; + border-radius: 5px; + border: none; + padding: 5px; + position: absolute; + right: 0; + top: 10px; + display: block; +} + +#detail #detailCloseButton:hover { + padding: 5px; + background: #ccc; +} + +#detail #detailCloseButton img { + display: block; /* get rid of the weird bottom border */ +} + +#detail #detailNotificationsDisallowed { + display: none; + color: darkred; +} + +#detail #events { + max-width: 900px; + margin: 0 auto 50px auto; +} diff --git a/server/static/img/close_black_24dp.svg b/server/static/img/close_black_24dp.svg new file mode 100644 index 0000000..5f1267d --- /dev/null +++ b/server/static/img/close_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/static/js/app.js b/server/static/js/app.js index 7e67631..d4de8de 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -10,36 +10,50 @@ /* All the things */ let topics = {}; +let currentTopic = ""; +let currentTopicUnsubscribeOnClose = false; +/* Main view */ +const main = document.getElementById("main"); const topicsHeader = document.getElementById("topicsHeader"); const topicsList = document.getElementById("topicsList"); const topicField = document.getElementById("topicField"); const notifySound = document.getElementById("notifySound"); const subscribeButton = document.getElementById("subscribeButton"); const errorField = document.getElementById("error"); +const originalTitle = document.title; + +/* Detail view */ +const detailView = document.getElementById("detail"); +const detailTitle = document.getElementById("detailTitle"); +const detailEventsList = document.getElementById("detailEventsList"); +const detailTopicUrl = document.getElementById("detailTopicUrl"); +const detailNoNotifications = document.getElementById("detailNoNotifications"); +const detailCloseButton = document.getElementById("detailCloseButton"); +const detailNotificationsDisallowed = document.getElementById("detailNotificationsDisallowed"); const subscribe = (topic) => { if (Notification.permission !== "granted") { Notification.requestPermission().then((permission) => { if (permission === "granted") { - subscribeInternal(topic, 0); + subscribeInternal(topic, true, 0); } else { showNotificationDeniedError(); } }); } else { - subscribeInternal(topic, 0); + subscribeInternal(topic, true,0); } }; -const subscribeInternal = (topic, delaySec) => { +const subscribeInternal = (topic, persist, delaySec) => { setTimeout(() => { // Render list entry let topicEntry = document.getElementById(`topic-${topic}`); if (!topicEntry) { topicEntry = document.createElement('li'); topicEntry.id = `topic-${topic}`; - topicEntry.innerHTML = `${topic} `; + topicEntry.innerHTML = `${topic} `; topicsList.appendChild(topicEntry); } topicsHeader.style.display = ''; @@ -47,30 +61,47 @@ const subscribeInternal = (topic, delaySec) => { // Open event source let eventSource = new EventSource(`${topic}/sse`); eventSource.onopen = () => { - topicEntry.innerHTML = `${topic} `; + topicEntry.innerHTML = `${topic} `; delaySec = 0; // Reset on successful connection }; eventSource.onerror = (e) => { - topicEntry.innerHTML = `${topic} (Reconnecting) `; + topicEntry.innerHTML = `${topic} (Reconnecting) `; eventSource.close(); const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15; - subscribeInternal(topic, newDelaySec); + subscribeInternal(topic, persist, newDelaySec); }; eventSource.onmessage = (e) => { const event = JSON.parse(e.data); - notifySound.play(); - new Notification(`${location.host}/${topic}`, { - body: event.message, - icon: '/static/img/favicon.png' - }); + topics[topic]['messages'].push(event); + topics[topic]['messages'].sort((a, b) => { return a.time < b.time; }) // Newest first + if (currentTopic === topic) { + rerenderDetailView(); + } + if (Notification.permission === "granted") { + notifySound.play(); + new Notification(`${location.host}/${topic}`, { + body: event.message, + icon: '/static/img/favicon.png' + }); + } }; - topics[topic] = eventSource; - localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); + topics[topic] = { + 'eventSource': eventSource, + 'messages': [], + 'persist': persist + }; + fetchCachedMessages(topic).then(() => { + if (currentTopic === topic) { + rerenderDetailView(); + } + }) + let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist); + localStorage.setItem('topics', JSON.stringify(persistedTopicKeys)); }, delaySec * 1000); }; const unsubscribe = (topic) => { - topics[topic].close(); + topics[topic]['eventSource'].close(); delete topics[topic]; localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); document.getElementById(`topic-${topic}`).remove(); @@ -83,7 +114,79 @@ const test = (topic) => { fetch(`/${topic}`, { method: 'PUT', body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.` + }); +}; + +const fetchCachedMessages = async (topic) => { + const topicJsonUrl = `/${topic}/json?poll=1&since=12h`; // Poll! + for await (let line of makeTextFileLineIterator(topicJsonUrl)) { + const message = JSON.parse(line); + topics[topic]['messages'].push(message); + } + topics[topic]['messages'].sort((a, b) => { return a.time < b.time; }) // Newest first +}; + +const showDetail = (topic) => { + currentTopic = topic; + history.replaceState(topic, `ntfy.sh/${topic}`, `/${topic}`); + window.scrollTo(0, 0); + rerenderDetailView(); + return false; +}; + +const rerenderDetailView = () => { + detailTitle.innerHTML = `ntfy.sh/${currentTopic}`; // document.location.replaceAll(..) + detailTopicUrl.innerHTML = `ntfy.sh/${currentTopic}`; + while (detailEventsList.firstChild) { + detailEventsList.removeChild(detailEventsList.firstChild); + } + topics[currentTopic]['messages'].forEach(m => { + let dateDiv = document.createElement('div'); + let messageDiv = document.createElement('div'); + let eventDiv = document.createElement('div'); + dateDiv.classList.add('detailDate'); + dateDiv.innerHTML = new Date(m.time * 1000).toLocaleString(); + messageDiv.classList.add('detailMessage'); + messageDiv.innerText = m.message; + eventDiv.appendChild(dateDiv); + eventDiv.appendChild(messageDiv); + detailEventsList.appendChild(eventDiv); }) + if (topics[currentTopic]['messages'].length === 0) { + detailNoNotifications.style.display = ''; + } else { + detailNoNotifications.style.display = 'none'; + } + if (Notification.permission === "granted") { + detailNotificationsDisallowed.style.display = 'none'; + } else { + detailNotificationsDisallowed.style.display = 'block'; + } + detailView.style.display = 'block'; + main.style.display = 'none'; +}; + +const hideDetailView = () => { + if (currentTopicUnsubscribeOnClose) { + unsubscribe(currentTopic); + currentTopicUnsubscribeOnClose = false; + } + currentTopic = ""; + history.replaceState('', originalTitle, '/'); + detailView.style.display = 'none'; + main.style.display = ''; + return false; +}; + +const requestPermission = () => { + if (Notification.permission !== "granted") { + Notification.requestPermission().then((permission) => { + if (permission === "granted") { + detailNotificationsDisallowed.style.display = 'none'; + } + }); + } + return false; }; const showError = (msg) => { @@ -100,7 +203,39 @@ const showNotificationDeniedError = () => { showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications."); }; -subscribeButton.onclick = function () { +// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch +async function* makeTextFileLineIterator(fileURL) { + const utf8Decoder = new TextDecoder('utf-8'); + const response = await fetch(fileURL); + const reader = response.body.getReader(); + let { value: chunk, done: readerDone } = await reader.read(); + chunk = chunk ? utf8Decoder.decode(chunk) : ''; + + const re = /\n|\r|\r\n/gm; + let startIndex = 0; + let result; + + for (;;) { + let result = re.exec(chunk); + if (!result) { + if (readerDone) { + break; + } + let remainder = chunk.substr(startIndex); + ({ value: chunk, done: readerDone } = await reader.read()); + chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ''); + startIndex = re.lastIndex = 0; + continue; + } + yield chunk.substring(startIndex, result.index); + startIndex = re.lastIndex; + } + if (startIndex < chunk.length) { + yield chunk.substr(startIndex); // last line didn't end in a newline char + } +} + +subscribeButton.onclick = () => { if (!topicField.value) { return false; } @@ -109,6 +244,10 @@ subscribeButton.onclick = function () { return false; }; +detailCloseButton.onclick = () => { + hideDetailView(); +}; + // Disable Web UI if notifications of EventSource are not available if (!window["Notification"] || !window["EventSource"]) { showBrowserIncompatibleError(); @@ -119,14 +258,27 @@ if (!window["Notification"] || !window["EventSource"]) { // Reset UI topicField.value = ""; +// (Temporarily) subscribe topic if we navigated to /sometopic URL +const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app! +if (match) { + currentTopic = match[1]; + subscribeInternal(currentTopic, false,0); +} + // Restore topics const storedTopics = localStorage.getItem('topics'); -if (storedTopics && Notification.permission === "granted") { - const storedTopicsArray = JSON.parse(storedTopics) - storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); }); +if (storedTopics) { + const storedTopicsArray = JSON.parse(storedTopics); + storedTopicsArray.forEach((topic) => { subscribeInternal(topic, true, 0); }); if (storedTopicsArray.length === 0) { topicsHeader.style.display = 'none'; } + if (currentTopic) { + currentTopicUnsubscribeOnClose = !storedTopicsArray.includes(currentTopic); + } } else { topicsHeader.style.display = 'none'; + if (currentTopic) { + currentTopicUnsubscribeOnClose = true; + } } diff --git a/util/util.go b/util/util.go index 7351622..eda167f 100644 --- a/util/util.go +++ b/util/util.go @@ -1,6 +1,7 @@ package util import ( + "fmt" "math/rand" "os" "time" @@ -27,3 +28,35 @@ func RandomString(length int) string { } return string(b) } + +// DurationToHuman converts a duration to a human readable format +func DurationToHuman(d time.Duration) (str string) { + if d == 0 { + return "0" + } + + d = d.Round(time.Second) + days := d / time.Hour / 24 + if days > 0 { + str += fmt.Sprintf("%dd", days) + } + d -= days * time.Hour * 24 + + hours := d / time.Hour + if hours > 0 { + str += fmt.Sprintf("%dh", hours) + } + d -= hours * time.Hour + + minutes := d / time.Minute + if minutes > 0 { + str += fmt.Sprintf("%dm", minutes) + } + d -= minutes * time.Minute + + seconds := d / time.Second + if seconds > 0 { + str += fmt.Sprintf("%ds", seconds) + } + return +}