Compare commits

...

9 Commits

Author SHA1 Message Date
Philipp C. Heckel
fff535ca1a Update README.md 2021-11-09 10:52:11 -05:00
Philipp C. Heckel
26390b9ad1 Update README.md 2021-11-09 10:51:21 -05:00
Philipp Heckel
cc752cf797 Screenshots 2021-11-09 10:46:47 -05:00
Philipp Heckel
4d48c5dc34 Examples 2021-11-08 20:15:13 -05:00
Philipp Heckel
b9b53bcdf0 Fix Chrome/Firefox inconsistencies with sorting 2021-11-08 10:35:46 -05:00
Philipp Heckel
a1385f6785 Update readme 2021-11-08 09:48:55 -05:00
Philipp Heckel
d453db89a7 Add since=all; make poll=1 default to since=all 2021-11-08 09:46:31 -05:00
Philipp Heckel
43c9a92748 Detail page in web UI 2021-11-08 09:24:34 -05:00
Philipp Heckel
c01c94c64c Fix content type to add charset 2021-11-07 13:08:03 -05:00
20 changed files with 554 additions and 63 deletions

View File

@@ -1,14 +1,22 @@
![ntfy](server/static/img/ntfy.png)
# ntfy - simple HTTP-based pub-sub
# ntfy.sh | simple HTTP-based pub-sub
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
**Ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
It's also open source (as you can plainly see) if you want to run your own.
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
too.
<p>
<img src="server/static/img/screenshot-curl.png" height="180">
<img src="server/static/img/screenshot-web-detail.png" height="180">
<img src="server/static/img/screenshot-phone-main.jpg" height="180">
<img src="server/static/img/screenshot-phone-detail.jpg" height="180">
<img src="server/static/img/screenshot-phone-notification.jpg" height="180">
</p>
## Usage
### Publishing messages
@@ -129,13 +137,13 @@ sudo apt install ntfy
**Debian/Ubuntu** (*manual install*)**:**
```bash
sudo apt install tmux
wget https://github.com/binwiederhier/ntfy/releases/download/v1.2.4/ntfy_1.2.4_amd64.deb
dpkg -i ntfy_1.2.4_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.3.0/ntfy_1.3.0_amd64.deb
dpkg -i ntfy_1.3.0_amd64.deb
```
**Fedora/RHEL/CentOS:**
```bash
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.2.4/ntfy_1.2.4_amd64.rpm
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.3.0/ntfy_1.3.0_amd64.rpm
```
**Docker:**
@@ -150,8 +158,8 @@ go get -u heckel.io/ntfy
**Manual install** (*any x86_64-based Linux*)**:**
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.2.4/ntfy_1.2.4_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_1.2.4_linux_x86_64.tar.gz ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.3.0/ntfy_1.3.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_1.3.0_linux_x86_64.tar.gz ntfy
./ntfy
```
@@ -183,3 +191,4 @@ Third party libraries and resources:
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)

View File

@@ -0,0 +1,7 @@
#!/bin/bash
# This is a PAM script hook that shows how to notify you when
# somebody logs into your server. Place at /usr/local/bin/ntfy-ssh-login.sh (with chmod +x!).
if [ "${PAM_TYPE}" = "open_session" ]; then
echo -en "\u26A0\uFE0F SSH login to $(hostname): ${PAM_USER} from ${PAM_RHOST}" | curl -T- ntfy.sh/alerts
fi

View File

@@ -0,0 +1,8 @@
# PAM config file snippet
#
# Put this snippet AT THE END of the file /etc/pam.d/sshd
# See https://geekthis.net/post/run-scripts-after-ssh-authentication/ for details.
# (lots of stuff here ...)
session optional pam_exec.so /usr/local/bin/ntfy-ssh-login.sh

View File

@@ -7,7 +7,7 @@ import (
type cache interface {
AddMessage(m *message) error
Messages(topic string, since time.Time) ([]*message, error)
Messages(topic string, since sinceTime) ([]*message, error)
MessageCount(topic string) (int, error)
Topics() (map[string]*topic, error)
Prune(keep time.Duration) error

View File

@@ -29,7 +29,7 @@ func (s *memCache) AddMessage(m *message) error {
return nil
}
func (s *memCache) Messages(topic string, since time.Time) ([]*message, error) {
func (s *memCache) Messages(topic string, since sinceTime) ([]*message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.messages[topic]; !ok {
@@ -38,7 +38,7 @@ func (s *memCache) Messages(topic string, since time.Time) ([]*message, error) {
messages := make([]*message, 0) // copy!
for _, m := range s.messages[topic] {
msgTime := time.Unix(m.Time, 0)
if msgTime == since || msgTime.After(since) {
if msgTime == since.Time() || msgTime.After(since.Time()) {
messages = append(messages, m)
}
}

View File

@@ -55,8 +55,8 @@ func (c *sqliteCache) AddMessage(m *message) error {
return err
}
func (c *sqliteCache) Messages(topic string, since time.Time) ([]*message, error) {
rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Unix())
func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error) {
rows, err := c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
if err != nil {
return nil, err
}

View File

@@ -1,3 +1,4 @@
{{- /*gotype: heckel.io/ntfy/server.indexPage*/ -}}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -27,19 +28,34 @@
<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" />
{{if .Topic}}
<!-- Never index topic page -->
<meta name="robots" content="noindex, nofollow" />
{{end}}
</head>
<body>
<div id="main">
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh - simple HTTP-based pub-sub</h1>
<div id="main"{{if .Topic}} style="display: none"{{end}}>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh | simple HTTP-based pub-sub</h1>
<p>
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service.
<b>Ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service.
It allows you to send notifications <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">to your phone</a> or desktop via scripts from any computer,
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>
<div id="screenshots">
<a href="static/img/screenshot-curl.png"><img src="static/img/screenshot-curl.png"/></a>
<a href="static/img/screenshot-web-detail.png"><img src="static/img/screenshot-web-detail.png"/></a>
<span class="nowrap">
<a href="static/img/screenshot-phone-main.jpg"><img src="static/img/screenshot-phone-main.jpg"/></a>
<a href="static/img/screenshot-phone-detail.jpg"><img src="static/img/screenshot-phone-detail.jpg"/></a>
<a href="static/img/screenshot-phone-notification.jpg"><img src="static/img/screenshot-phone-notification.jpg"/></a>
</span>
</div>
<p>
There are many ways to use ntfy. You can send yourself messages for all sorts of things: When a long process finishes or fails (a backup, a long rsync job, ...),
There are many ways to use Ntfy. You can send yourself messages for all sorts of things: When a long process finishes or fails (a backup, a long rsync job, ...),
or to notify yourself when somebody logs into your server(s). Or you may want to use it in your own app to distribute messages to subscribed clients.
Endless possibilities 😀.
Endless possibilities 😀. Be sure to check out the <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">example on GitHub</a>!
</p>
<h2>Publishing messages</h2>
@@ -79,7 +95,7 @@
<form id="subscribeForm">
<p>
<b>Topic:</b><br/>
<input type="text" id="topicField" autocomplete="off" placeholder="Topic name, e.g. phil_alerts" pattern="[-_A-Za-z]{1,64}" />
<input type="text" id="topicField" autocomplete="off" placeholder="Topic name, e.g. phil_alerts" maxlength="64" pattern="[-_A-Za-z0-9]{1,64}" />
<button id="subscribeButton">Subscribe</button>
</p>
<p id="topicsHeader"><b>Subscribed topics:</b></p>
@@ -209,6 +225,32 @@
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
</div>
<div id="detail"{{if not .Topic}} style="display: none"{{end}}>
<div id="detailMain">
<button id="detailCloseButton"><img src="static/img/close_black_24dp.svg"/></button>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/><span id="detailTitle"></span></h1>
<p class="smallMarginBottom">
<b>Ntfy</b> is a simple HTTP-based pub-sub notification service. This is a Ntfy topic.
To send notifications to it, simply PUT or POST to the topic URL. Here's an example using <tt>curl</tt>:
</p>
<code>
curl -d "Backup failed" <span id="detailTopicUrl"></span>
</code>
<p id="detailNotificationsDisallowed">
If you'd like to receive desktop notifications when new messages arrive on this topic, you have
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
Click the link to do so.
</p>
<p class="smallMarginBottom">
<b>Recent notifications</b> (cached for {{.CacheDuration}}):
</p>
<p id="detailNoNotifications">
<i>You haven't received any notifications for this topic yet.</i>
</p>
<div id="detailEventsList"></div>
</div>
</div>
<div id="lightbox" class="lightbox"></div>
<script src="static/js/app.js"></script>
</body>
</html>

View File

@@ -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,45 @@ func (e errHTTP) Error() string {
return fmt.Sprintf("http: %s", e.Status)
}
type indexPage struct {
Topic string
CacheDuration string
}
type sinceTime time.Time
func (t sinceTime) IsAll() bool {
return t == sinceAllMessages
}
func (t sinceTime) IsNone() bool {
return t == sinceNoMessages
}
func (t sinceTime) Time() time.Time {
return time.Time(t)
}
var (
sinceAllMessages = sinceTime(time.Unix(0, 0))
sinceNoMessages = sinceTime(time.Unix(1, 0))
)
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 +186,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 +207,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 {
@@ -228,7 +257,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
}
return buf.String(), nil
}
return s.handleSubscribe(w, r, v, "json", "application/stream+json", encoder)
return s.handleSubscribe(w, r, v, "json", "application/x-ndjson", encoder)
}
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -282,8 +311,8 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
}
return nil
}
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType)
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
if poll {
return s.sendOldMessages(t, since, sub)
}
@@ -308,8 +337,8 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
}
}
func (s *Server) sendOldMessages(t *topic, since time.Time, sub subscriber) error {
if since.IsZero() {
func (s *Server) sendOldMessages(t *topic, since sinceTime, sub subscriber) error {
if since.IsNone() {
return nil
}
messages, err := s.cache.Messages(t.id, since)
@@ -324,17 +353,27 @@ func (s *Server) sendOldMessages(t *topic, since time.Time, sub subscriber) erro
return nil
}
func parseSince(r *http.Request) (time.Time, error) {
// parseSince returns a timestamp identifying the time span from which cached messages should be received.
//
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h), or
// "all" for all messages.
func parseSince(r *http.Request) (sinceTime, error) {
if !r.URL.Query().Has("since") {
return time.Time{}, nil
if r.URL.Query().Has("poll") {
return sinceAllMessages, nil
}
return sinceNoMessages, nil
}
if since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64); err == nil {
return time.Unix(since, 0), nil
if r.URL.Query().Get("since") == "all" {
return sinceAllMessages, nil
}
if s, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64); err == nil {
return sinceTime(time.Unix(s, 0)), nil
}
if d, err := time.ParseDuration(r.URL.Query().Get("since")); err == nil {
return time.Now().Add(-1 * d), nil
return sinceTime(time.Now().Add(-1 * d)), nil
}
return time.Time{}, errHTTPBadRequest
return sinceNoMessages, errHTTPBadRequest
}
func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {

View File

@@ -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;
}
@@ -89,6 +95,83 @@ code {
color: #666;
}
/* Screenshots */
#screenshots {
text-align: center;
}
#screenshots img {
height: 190px;
margin: 3px;
border-radius: 5px;
filter: drop-shadow(2px 2px 2px #ddd);
}
#screenshots .nowrap {
white-space: nowrap;
}
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
.lightbox {
opacity: 0;
visibility: hidden;
position: fixed;
left:0;
right: 0;
top: 0;
bottom: 0;
z-index: -1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in;
}
.lightbox.show {
background-color: rgba(0,0,0, 0.75);
opacity: 1;
visibility: visible;
z-index: 1000;
}
.lightbox img {
max-width: 90%;
max-height: 90%;
filter: drop-shadow(5px 5px 10px #222);
border-radius: 5px;
}
.lightbox .close-lightbox {
cursor: pointer;
position: absolute;
top: 30px;
right: 30px;
width: 20px;
height: 20px;
}
.lightbox .close-lightbox::after,
.lightbox .close-lightbox::before {
content: '';
width: 3px;
height: 20px;
background-color: #ddd;
position: absolute;
border-radius: 5px;
transform: rotate(45deg);
}
.lightbox .close-lightbox::before {
transform: rotate(-45deg);
}
.lightbox .close-lightbox:hover::after,
.lightbox .close-lightbox:hover::before {
background-color: #fff;
}
/* Subscribe box */
button {
@@ -107,7 +190,7 @@ button:hover {
ul {
padding-left: 1em;
list-style-type: none;
list-style-type: circle;
padding-bottom: 0;
margin: 0;
}
@@ -146,7 +229,6 @@ li {
#subscribeBox ul {
margin: 0;
padding: 0;
}
#subscribeBox li {
@@ -160,6 +242,10 @@ li {
vertical-align: bottom;
}
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button {
font-size: 0.8em;
background: #3a9784;
@@ -202,7 +288,6 @@ li {
#subscribeBox ul {
margin: 0;
padding: 0;
}
#subscribeBox input {
@@ -228,6 +313,10 @@ li {
vertical-align: bottom;
}
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button {
font-size: 0.7em;
background: #3a9784;
@@ -240,7 +329,63 @@ 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;
position: relative; /* required for close button's "position: absolute" */
padding-bottom: 50px; /* Chrome and Firefox behave differently regarding bottom margin */
}
#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;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -10,36 +10,53 @@
/* 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");
/* Screenshots */
const lightbox = document.getElementById("lightbox");
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} <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
topicsList.appendChild(topicEntry);
}
topicsHeader.style.display = '';
@@ -47,30 +64,47 @@ const subscribeInternal = (topic, delaySec) => {
// Open event source
let eventSource = new EventSource(`${topic}/sse`);
eventSource.onopen = () => {
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
delaySec = 0; // Reset on successful connection
};
eventSource.onerror = (e) => {
topicEntry.innerHTML = `${topic} <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
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 ? 1 : -1; }); // 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 +117,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`; // 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 ? 1 : -1; }); // 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 +206,87 @@ 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 () {
const showScreenshotOverlay = (e, el, index) => {
lightbox.classList.add('show');
document.addEventListener('keydown', nextScreenshotKeyboardListener);
return showScreenshot(e, index);
};
const showScreenshot = (e, index) => {
const actualIndex = resolveScreenshotIndex(index);
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
currentScreenshotIndex = actualIndex;
e.stopPropagation();
return false;
};
const nextScreenshot = (e) => {
return showScreenshot(e, currentScreenshotIndex+1);
};
const previousScreenshot = (e) => {
return showScreenshot(e, currentScreenshotIndex-1);
};
const resolveScreenshotIndex = (index) => {
if (index < 0) {
return screenshots.length - 1;
} else if (index > screenshots.length - 1) {
return 0;
}
return index;
};
const hideScreenshotOverlay = (e) => {
lightbox.classList.remove('show');
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
};
const nextScreenshotKeyboardListener = (e) => {
switch (e.keyCode) {
case 37:
previousScreenshot(e);
break;
case 39:
nextScreenshot(e);
break;
}
};
// 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 +295,18 @@ subscribeButton.onclick = function () {
return false;
};
detailCloseButton.onclick = () => {
hideDetailView();
};
let currentScreenshotIndex = 0;
const screenshots = [...document.querySelectorAll("#screenshots a")];
screenshots.forEach((el, index) => {
el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
});
lightbox.onclick = hideScreenshotOverlay;
// Disable Web UI if notifications of EventSource are not available
if (!window["Notification"] || !window["EventSource"]) {
showBrowserIncompatibleError();
@@ -120,13 +318,22 @@ if (!window["Notification"] || !window["EventSource"]) {
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) {
const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
if (storedTopics) {
storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
if (storedTopics.length === 0) {
topicsHeader.style.display = 'none';
}
} else {
topicsHeader.style.display = 'none';
}
// (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];
if (!storedTopics.includes(currentTopic)) {
subscribeInternal(currentTopic, false,0);
currentTopicUnsubscribeOnClose = true;
}
}

View File

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