Compare commits

...

11 Commits

Author SHA1 Message Date
Philipp Heckel
ffe0c72a5a Bump version 2021-11-16 13:11:31 -05:00
Philipp Heckel
52136030be Subscribe to more than one topic 2021-11-15 07:56:58 -05:00
Philipp Heckel
a481f4c448 Theme color in Chrome Android 2021-11-12 14:43:32 -05:00
Philipp Heckel
9b171dee8b Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-09 14:48:48 -05:00
Philipp Heckel
c0ee174b13 Make publishing asynchronous 2021-11-09 14:48:25 -05:00
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
16 changed files with 264 additions and 66 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.4.0/ntfy_1.3.0_amd64.deb
dpkg -i ntfy_1.4.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.4.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.4.0/ntfy_1.3.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_1.4.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

@@ -13,9 +13,9 @@
<meta name="HandheldFriendly" content="true">
<!-- Mobile browsers, background color -->
<meta name="theme-color" content="#39005a">
<meta name="msapplication-navbutton-color" content="#39005a">
<meta name="apple-mobile-web-app-status-bar-style" content="#39005a">
<meta name="theme-color" content="#317f6f">
<meta name="msapplication-navbutton-color" content="#317f6f">
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="static/img/favicon.png">
@@ -41,10 +41,21 @@
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>
@@ -239,6 +250,7 @@
<div id="detailEventsList"></div>
</div>
</div>
<div id="lightbox" class="lightbox"></div>
<script src="static/js/app.js"></script>
</body>
</html>

View File

@@ -78,9 +78,9 @@ const (
var (
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$`)
jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
staticRegex = regexp.MustCompile(`^/static/.+`)
@@ -223,7 +223,7 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
t, err := s.topic(r.URL.Path[1:])
t, err := s.topicFromID(r.URL.Path[1:])
if err != nil {
return err
}
@@ -289,7 +289,9 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
return errHTTPTooManyRequests
}
defer v.RemoveSubscription()
t, err := s.topic(strings.TrimSuffix(r.URL.Path[1:], "/"+format)) // Hack
topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
topicIDs := strings.Split(topicsStr, ",")
topics, err := s.topicsFromIDs(topicIDs...)
if err != nil {
return err
}
@@ -314,14 +316,21 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
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)
return s.sendOldMessages(topics, since, sub)
}
subscriberID := t.Subscribe(sub)
defer t.Unsubscribe(subscriberID)
if err := sub(newOpenMessage(t.id)); err != nil { // Send out open message
subscriberIDs := make([]int, 0)
for _, t := range topics {
subscriberIDs = append(subscriberIDs, t.Subscribe(sub))
}
defer func() {
for i, subscriberID := range subscriberIDs {
topics[i].Unsubscribe(subscriberID) // Order!
}
}()
if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
if err := s.sendOldMessages(t, since, sub); err != nil {
if err := s.sendOldMessages(topics, since, sub); err != nil {
return err
}
for {
@@ -330,25 +339,27 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
return nil
case <-time.After(s.config.KeepaliveInterval):
v.Keepalive()
if err := sub(newKeepaliveMessage(t.id)); err != nil { // Send keepalive message
if err := sub(newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
return err
}
}
}
}
func (s *Server) sendOldMessages(t *topic, since sinceTime, sub subscriber) error {
func (s *Server) sendOldMessages(topics []*topic, since sinceTime, sub subscriber) error {
if since.IsNone() {
return nil
}
messages, err := s.cache.Messages(t.id, since)
if err != nil {
return err
}
for _, m := range messages {
if err := sub(m); err != nil {
for _, t := range topics {
messages, err := s.cache.Messages(t.id, since)
if err != nil {
return err
}
for _, m := range messages {
if err := sub(m); err != nil {
return err
}
}
}
return nil
}
@@ -382,19 +393,31 @@ func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) topic(id string) (*topic, error) {
func (s *Server) topicFromID(id string) (*topic, error) {
topics, err := s.topicsFromIDs(id)
if err != nil {
return nil, err
}
return topics[0], nil
}
func (s *Server) topicsFromIDs(ids... string) ([]*topic, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.topics[id]; !ok {
if len(s.topics) >= s.config.GlobalTopicLimit {
return nil, errHTTPTooManyRequests
}
s.topics[id] = newTopic(id, time.Now())
if s.firebase != nil {
s.topics[id].Subscribe(s.firebase)
topics := make([]*topic, 0)
for _, id := range ids {
if _, ok := s.topics[id]; !ok {
if len(s.topics) >= s.config.GlobalTopicLimit {
return nil, errHTTPTooManyRequests
}
s.topics[id] = newTopic(id, time.Now())
if s.firebase != nil {
s.topics[id].Subscribe(s.firebase)
}
}
topics = append(topics, s.topics[id])
}
return s.topics[id], nil
return topics, nil
}
func (s *Server) updateStatsAndExpire() {

View File

@@ -95,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 {
@@ -278,8 +355,9 @@ li {
#detail #detailMain {
max-width: 900px;
margin: 0 auto 50px auto;
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 {

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

@@ -32,6 +32,9 @@ 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) => {
@@ -73,7 +76,7 @@ const subscribeInternal = (topic, persist, delaySec) => {
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
topics[topic]['messages'].push(event);
topics[topic]['messages'].sort((a, b) => { return a.time < b.time; }) // Newest first
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
if (currentTopic === topic) {
rerenderDetailView();
}
@@ -123,7 +126,7 @@ const fetchCachedMessages = async (topic) => {
const message = JSON.parse(line);
topics[topic]['messages'].push(message);
}
topics[topic]['messages'].sort((a, b) => { return a.time < b.time; }) // Newest first
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
};
const showDetail = (topic) => {
@@ -203,6 +206,54 @@ const showNotificationDeniedError = () => {
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
};
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');
@@ -248,6 +299,14 @@ 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();
@@ -258,27 +317,23 @@ if (!window["Notification"] || !window["EventSource"]) {
// Reset UI
topicField.value = "";
// Restore topics
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];
subscribeInternal(currentTopic, false,0);
}
// Restore topics
const storedTopics = localStorage.getItem('topics');
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) {
if (!storedTopics.includes(currentTopic)) {
subscribeInternal(currentTopic, false,0);
currentTopicUnsubscribeOnClose = true;
}
}

View File

@@ -28,6 +28,7 @@ func newTopic(id string, last time.Time) *topic {
}
}
// Subscribe subscribes to this topic
func (t *topic) Subscribe(s subscriber) int {
t.mu.Lock()
defer t.mu.Unlock()
@@ -37,24 +38,29 @@ func (t *topic) Subscribe(s subscriber) int {
return subscriberID
}
// Unsubscribe removes the subscription from the list of subscribers
func (t *topic) Unsubscribe(id int) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.subscribers, id)
}
// Publish asynchronously publishes to all subscribers
func (t *topic) Publish(m *message) error {
t.mu.Lock()
defer t.mu.Unlock()
t.last = time.Now()
for _, s := range t.subscribers {
if err := s(m); err != nil {
log.Printf("error publishing message to subscriber")
go func() {
t.mu.Lock()
defer t.mu.Unlock()
t.last = time.Now()
for _, s := range t.subscribers {
if err := s(m); err != nil {
log.Printf("error publishing message to subscriber")
}
}
}
}()
return nil
}
// Subscribers returns the number of subscribers to this topic
func (t *topic) Subscribers() int {
t.mu.Lock()
defer t.mu.Unlock()