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](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 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. 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) 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. 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 ## Usage
### Publishing messages ### Publishing messages
@@ -129,13 +137,13 @@ sudo apt install ntfy
**Debian/Ubuntu** (*manual install*)**:** **Debian/Ubuntu** (*manual install*)**:**
```bash ```bash
sudo apt install tmux sudo apt install tmux
wget https://github.com/binwiederhier/ntfy/releases/download/v1.2.4/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.2.4_amd64.deb dpkg -i ntfy_1.3.0_amd64.deb
``` ```
**Fedora/RHEL/CentOS:** **Fedora/RHEL/CentOS:**
```bash ```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:** **Docker:**
@@ -150,8 +158,8 @@ go get -u heckel.io/ntfy
**Manual install** (*any x86_64-based Linux*)**:** **Manual install** (*any x86_64-based Linux*)**:**
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.2.4/ntfy_1.2.4_linux_x86_64.tar.gz 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.2.4_linux_x86_64.tar.gz ntfy sudo tar -C /usr/bin -zxf ntfy_1.3.0_linux_x86_64.tar.gz ntfy
./ntfy ./ntfy
``` ```
@@ -183,3 +191,4 @@ Third party libraries and resources:
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases * [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 * [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 * [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 { type cache interface {
AddMessage(m *message) error AddMessage(m *message) error
Messages(topic string, since time.Time) ([]*message, error) Messages(topic string, since sinceTime) ([]*message, error)
MessageCount(topic string) (int, error) MessageCount(topic string) (int, error)
Topics() (map[string]*topic, error) Topics() (map[string]*topic, error)
Prune(keep time.Duration) error Prune(keep time.Duration) error

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
{{- /*gotype: heckel.io/ntfy/server.indexPage*/ -}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <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: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:image" content="/static/img/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" /> <meta property="og:url" content="https://ntfy.sh" />
{{if .Topic}}
<!-- Never index topic page -->
<meta name="robots" content="noindex, nofollow" />
{{end}}
</head> </head>
<body> <body>
<div id="main"> <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> <h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh | simple HTTP-based pub-sub</h1>
<p> <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, 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. 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>
<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> <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. 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> </p>
<h2>Publishing messages</h2> <h2>Publishing messages</h2>
@@ -79,7 +95,7 @@
<form id="subscribeForm"> <form id="subscribeForm">
<p> <p>
<b>Topic:</b><br/> <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> <button id="subscribeButton">Subscribe</button>
</p> </p>
<p id="topicsHeader"><b>Subscribed topics:</b></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> <center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
</div> </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> <script src="static/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -11,6 +11,8 @@ import (
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"
"heckel.io/ntfy/config" "heckel.io/ntfy/config"
"heckel.io/ntfy/util"
"html/template"
"io" "io"
"log" "log"
"net" "net"
@@ -46,20 +48,45 @@ func (e errHTTP) Error() string {
return fmt.Sprintf("http: %s", e.Status) 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 ( const (
messageLimit = 512 messageLimit = 512
) )
var ( var (
topicRegex = regexp.MustCompile(`^/[^/]+$`) topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`) jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/json$`)
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`) sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/sse$`)
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`) rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/raw$`)
staticRegex = regexp.MustCompile(`^/static/.+`) staticRegex = regexp.MustCompile(`^/static/.+`)
//go:embed "index.html" //go:embed "index.gohtml"
indexSource string indexSource string
indexTemplate = template.Must(template.New("index").Parse(indexSource))
//go:embed static //go:embed static
webStaticFs embed.FS 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 { 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) return s.handleHome(w, r)
} else if r.Method == http.MethodHead && r.URL.Path == "/" { } else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.handleEmpty(w, r) 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 { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
_, err := io.WriteString(w, indexSource) return indexTemplate.Execute(w, &indexPage{
return err Topic: r.URL.Path[1:],
CacheDuration: util.DurationToHuman(s.config.CacheDuration),
})
} }
func (s *Server) handleEmpty(w http.ResponseWriter, r *http.Request) error { 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 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 { 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 return nil
} }
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
if poll { if poll {
return s.sendOldMessages(t, since, sub) 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 { func (s *Server) sendOldMessages(t *topic, since sinceTime, sub subscriber) error {
if since.IsZero() { if since.IsNone() {
return nil return nil
} }
messages, err := s.cache.Messages(t.id, since) 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 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") { 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 { if r.URL.Query().Get("since") == "all" {
return time.Unix(since, 0), nil 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 { 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 { func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {

View File

@@ -6,6 +6,12 @@ html, body {
font-size: 1.1em; 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 { a, a:visited {
color: #3a9784; color: #3a9784;
} }
@@ -89,6 +95,83 @@ code {
color: #666; 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 */ /* Subscribe box */
button { button {
@@ -107,7 +190,7 @@ button:hover {
ul { ul {
padding-left: 1em; padding-left: 1em;
list-style-type: none; list-style-type: circle;
padding-bottom: 0; padding-bottom: 0;
margin: 0; margin: 0;
} }
@@ -146,7 +229,6 @@ li {
#subscribeBox ul { #subscribeBox ul {
margin: 0; margin: 0;
padding: 0;
} }
#subscribeBox li { #subscribeBox li {
@@ -160,6 +242,10 @@ li {
vertical-align: bottom; vertical-align: bottom;
} }
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button { #subscribeBox button {
font-size: 0.8em; font-size: 0.8em;
background: #3a9784; background: #3a9784;
@@ -202,7 +288,6 @@ li {
#subscribeBox ul { #subscribeBox ul {
margin: 0; margin: 0;
padding: 0;
} }
#subscribeBox input { #subscribeBox input {
@@ -228,6 +313,10 @@ li {
vertical-align: bottom; vertical-align: bottom;
} }
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button { #subscribeBox button {
font-size: 0.7em; font-size: 0.7em;
background: #3a9784; background: #3a9784;
@@ -240,7 +329,63 @@ li {
#subscribeBox button:hover { #subscribeBox button:hover {
background: #317f6f; 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 */ /* All the things */
let topics = {}; let topics = {};
let currentTopic = "";
let currentTopicUnsubscribeOnClose = false;
/* Main view */
const main = document.getElementById("main");
const topicsHeader = document.getElementById("topicsHeader"); const topicsHeader = document.getElementById("topicsHeader");
const topicsList = document.getElementById("topicsList"); const topicsList = document.getElementById("topicsList");
const topicField = document.getElementById("topicField"); const topicField = document.getElementById("topicField");
const notifySound = document.getElementById("notifySound"); const notifySound = document.getElementById("notifySound");
const subscribeButton = document.getElementById("subscribeButton"); const subscribeButton = document.getElementById("subscribeButton");
const errorField = document.getElementById("error"); 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) => { const subscribe = (topic) => {
if (Notification.permission !== "granted") { if (Notification.permission !== "granted") {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
if (permission === "granted") { if (permission === "granted") {
subscribeInternal(topic, 0); subscribeInternal(topic, true, 0);
} else { } else {
showNotificationDeniedError(); showNotificationDeniedError();
} }
}); });
} else { } else {
subscribeInternal(topic, 0); subscribeInternal(topic, true,0);
} }
}; };
const subscribeInternal = (topic, delaySec) => { const subscribeInternal = (topic, persist, delaySec) => {
setTimeout(() => { setTimeout(() => {
// Render list entry // Render list entry
let topicEntry = document.getElementById(`topic-${topic}`); let topicEntry = document.getElementById(`topic-${topic}`);
if (!topicEntry) { if (!topicEntry) {
topicEntry = document.createElement('li'); topicEntry = document.createElement('li');
topicEntry.id = `topic-${topic}`; 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); topicsList.appendChild(topicEntry);
} }
topicsHeader.style.display = ''; topicsHeader.style.display = '';
@@ -47,30 +64,47 @@ const subscribeInternal = (topic, delaySec) => {
// Open event source // Open event source
let eventSource = new EventSource(`${topic}/sse`); let eventSource = new EventSource(`${topic}/sse`);
eventSource.onopen = () => { 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 delaySec = 0; // Reset on successful connection
}; };
eventSource.onerror = (e) => { 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(); eventSource.close();
const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15; const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
subscribeInternal(topic, newDelaySec); subscribeInternal(topic, persist, newDelaySec);
}; };
eventSource.onmessage = (e) => { eventSource.onmessage = (e) => {
const event = JSON.parse(e.data); const event = JSON.parse(e.data);
notifySound.play(); topics[topic]['messages'].push(event);
new Notification(`${location.host}/${topic}`, { topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
body: event.message, if (currentTopic === topic) {
icon: '/static/img/favicon.png' rerenderDetailView();
}); }
if (Notification.permission === "granted") {
notifySound.play();
new Notification(`${location.host}/${topic}`, {
body: event.message,
icon: '/static/img/favicon.png'
});
}
}; };
topics[topic] = eventSource; topics[topic] = {
localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); '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); }, delaySec * 1000);
}; };
const unsubscribe = (topic) => { const unsubscribe = (topic) => {
topics[topic].close(); topics[topic]['eventSource'].close();
delete topics[topic]; delete topics[topic];
localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
document.getElementById(`topic-${topic}`).remove(); document.getElementById(`topic-${topic}`).remove();
@@ -83,7 +117,79 @@ const test = (topic) => {
fetch(`/${topic}`, { fetch(`/${topic}`, {
method: 'PUT', method: 'PUT',
body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.` 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) => { 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."); 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) { if (!topicField.value) {
return false; return false;
} }
@@ -109,6 +295,18 @@ subscribeButton.onclick = function () {
return false; 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 // Disable Web UI if notifications of EventSource are not available
if (!window["Notification"] || !window["EventSource"]) { if (!window["Notification"] || !window["EventSource"]) {
showBrowserIncompatibleError(); showBrowserIncompatibleError();
@@ -120,13 +318,22 @@ if (!window["Notification"] || !window["EventSource"]) {
topicField.value = ""; topicField.value = "";
// Restore topics // Restore topics
const storedTopics = localStorage.getItem('topics'); const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
if (storedTopics && Notification.permission === "granted") { if (storedTopics) {
const storedTopicsArray = JSON.parse(storedTopics) storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); }); if (storedTopics.length === 0) {
if (storedTopicsArray.length === 0) {
topicsHeader.style.display = 'none'; topicsHeader.style.display = 'none';
} }
} else { } else {
topicsHeader.style.display = 'none'; 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 package util
import ( import (
"fmt"
"math/rand" "math/rand"
"os" "os"
"time" "time"
@@ -27,3 +28,35 @@ func RandomString(length int) string {
} }
return string(b) 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
}