Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
317621c696 | ||
|
|
39574c954b | ||
|
|
1ab0282101 | ||
|
|
d6a7c3f5b0 | ||
|
|
d9e2e11a0b | ||
|
|
d9b9a62048 | ||
|
|
c3b97365ce | ||
|
|
23cf77e0b7 |
73
README.md
73
README.md
@@ -6,11 +6,14 @@ via scripts. I run a free version of it on *[ntfy.sh](https://ntfy.sh)*. **No si
|
||||
## Usage
|
||||
|
||||
### Subscribe to a topic
|
||||
You can subscribe to a topic either in a web UI, or in your own app by subscribing to an
|
||||
[SSE](https://en.wikipedia.org/wiki/Server-sent_events)/[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource),
|
||||
Topics are created on the fly by subscribing to them. You can create and subscribe to a topic either in a web UI, or in
|
||||
your own app by subscribing to an [SSE](https://en.wikipedia.org/wiki/Server-sent_events)/[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource),
|
||||
or a JSON or raw feed.
|
||||
|
||||
Here's how to see the raw/json/sse stream in `curl`. This will subscribe to the topic and wait for events.
|
||||
Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable.
|
||||
|
||||
Here's how you can create a topic `mytopic`, subscribe to it topic and wait for events. This is using `curl`, but you
|
||||
can use any library that can do HTTP GETs:
|
||||
|
||||
```
|
||||
# Subscribe to "mytopic" and output one message per line (\n are replaced with a space)
|
||||
@@ -40,6 +43,64 @@ curl -d "long process is done" ntfy.sh/mytopic
|
||||
Messages published to a non-existing topic or a topic without subscribers will not be delivered later. There is (currently)
|
||||
no buffering of any kind. If you're not listening, the message won't be delivered.
|
||||
|
||||
## Installation
|
||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||
deb/rpm packages.
|
||||
|
||||
1. Install ntfy using one of the methods described below
|
||||
2. Then (optionally) edit `/etc/ntfy/config.yml`
|
||||
3. Then just run it with `ntfy` (or `systemctl start ntfy` when using the deb/rpm).
|
||||
|
||||
### Binaries and packages
|
||||
**Debian/Ubuntu** (*from a repository*)**:**
|
||||
```bash
|
||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=amd64] https://archive.heckel.io/apt debian main' > /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
```
|
||||
|
||||
**Debian/Ubuntu** (*manual install*)**:**
|
||||
```bash
|
||||
sudo apt install tmux
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v0.0.2/ntfy_0.0.2_amd64.deb
|
||||
dpkg -i ntfy_0.0.2_amd64.deb
|
||||
```
|
||||
|
||||
**Fedora/RHEL/CentOS:**
|
||||
```bash
|
||||
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v0.0.2/ntfy_0.0.2_amd64.rpm
|
||||
```
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker run --rm -it binwiederhier/ntfy
|
||||
```
|
||||
|
||||
**Go:**
|
||||
```bash
|
||||
go get -u heckel.io/ntfy
|
||||
```
|
||||
|
||||
**Manual install** (*any x86_64-based Linux*)**:**
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v0.0.2/ntfy_0.0.2_linux_x86_64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_0.0.2_linux_x86_64.tar.gz ntfy
|
||||
./ntfy
|
||||
```
|
||||
|
||||
## Building
|
||||
Building ntfy is simple. Here's how you do it:
|
||||
|
||||
```
|
||||
make build-simple
|
||||
# Builds to dist/ntfy_linux_amd64/ntfy
|
||||
```
|
||||
|
||||
To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that installed, you can run `make build` or
|
||||
`make build-snapshot`.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Isn't this like ...?
|
||||
@@ -54,9 +115,11 @@ Best effort.
|
||||
### Why is the web UI so ugly?
|
||||
I don't particularly like JS or dealing with CSS. I'll make it pretty after it's functional.
|
||||
|
||||
### Will you know what topics exist, can you spy on me?
|
||||
If you don't trust me or your messages are sensitive, run your ntfy on your own server. That said, the logs do not
|
||||
contain any topic names or other details about you.
|
||||
|
||||
## TODO
|
||||
- rate limiting / abuse protection
|
||||
- release/packaging
|
||||
- add HTTPS
|
||||
|
||||
## Contributing
|
||||
|
||||
BIN
assets/favicon.xcf
Normal file
BIN
assets/favicon.xcf
Normal file
Binary file not shown.
@@ -1,18 +1,38 @@
|
||||
// Package config provides the main configuration
|
||||
package config
|
||||
|
||||
import (
|
||||
"golang.org/x/time/rate"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defines default config settings
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultListenHTTP = ":80"
|
||||
defaultManagerInterval = time.Minute
|
||||
)
|
||||
|
||||
// Defines the max number of requests, here:
|
||||
// 50 requests bucket, replenished at a rate of 1 per second
|
||||
var (
|
||||
defaultLimit = rate.Every(time.Second)
|
||||
defaultLimitBurst = 50
|
||||
)
|
||||
|
||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||
type Config struct {
|
||||
ListenHTTP string
|
||||
ListenHTTP string
|
||||
Limit rate.Limit
|
||||
LimitBurst int
|
||||
ManagerInterval time.Duration
|
||||
}
|
||||
|
||||
// New instantiates a default new config
|
||||
func New(listenHTTP string) *Config {
|
||||
return &Config{
|
||||
ListenHTTP: listenHTTP,
|
||||
ListenHTTP: listenHTTP,
|
||||
Limit: defaultLimit,
|
||||
LimitBurst: defaultLimitBurst,
|
||||
ManagerInterval: defaultManagerInterval,
|
||||
}
|
||||
}
|
||||
|
||||
54
examples/example_eventsource_sse.html
Normal file
54
examples/example_eventsource_sse.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ntfy.sh: EventSource Example</title>
|
||||
<style>
|
||||
body { font-size: 1.2em; line-height: 130%; }
|
||||
#events { font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ntfy.sh: EventSource Example</h1>
|
||||
<p>
|
||||
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
|
||||
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>.<br/>
|
||||
</p>
|
||||
<button id="publishButton">Send test notification</button>
|
||||
<p><b>Log:</b></p>
|
||||
<div id="events"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const publishURL = `https://ntfy.sh/example`;
|
||||
const subscribeURL = `https://ntfy.sh/example/sse`;
|
||||
const events = document.getElementById('events');
|
||||
const eventSource = new EventSource(subscribeURL);
|
||||
|
||||
// Publish button
|
||||
document.getElementById("publishButton").onclick = () => {
|
||||
fetch(publishURL, {
|
||||
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
|
||||
body: `It is ${new Date().toString()}. This is a test.`
|
||||
})
|
||||
};
|
||||
|
||||
// Incoming events
|
||||
eventSource.onopen = () => {
|
||||
let event = document.createElement('div');
|
||||
event.innerHTML = `EventSource connected to ${subscribeURL}`;
|
||||
events.appendChild(event);
|
||||
};
|
||||
eventSource.onerror = (e) => {
|
||||
let event = document.createElement('div');
|
||||
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
|
||||
events.appendChild(event);
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
let event = document.createElement('div');
|
||||
event.innerHTML = e.data;
|
||||
events.appendChild(event);
|
||||
};
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1
go.mod
1
go.mod
@@ -6,5 +6,6 @@ require (
|
||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
9
go.sum
9
go.sum
@@ -1,23 +1,20 @@
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
|
||||
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.3 h1:fvjTMHxHEw/mxHbtzPi3JCcKXQRAnQTBRo6YCJSVHKI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
@@ -1,153 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>ntfy.sh</title>
|
||||
<style>
|
||||
body { font-size: 1.3em; line-height: 140%; }
|
||||
#error { color: darkred; font-style: italic; }
|
||||
#main { max-width: 800px; margin: 0 auto; }
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<title>ntfy.sh | simple HTTP-based pub-sub</title>
|
||||
<link rel="stylesheet" href="static/css/app.css" type="text/css">
|
||||
|
||||
<!-- Mobile view -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="HandheldFriendly" content="true">
|
||||
|
||||
<!-- Mobile browsers, background color -->
|
||||
<meta name="theme-color" content="#004c79">
|
||||
<meta name="msapplication-navbutton-color" content="#004c79">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#004c79">
|
||||
|
||||
<!-- Favicon, see favicon.io -->
|
||||
<link rel="icon" type="image/png" href="static/img/favicon.png">
|
||||
|
||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="ntfy.sh" />
|
||||
<meta property="og:title" content="ntfy.sh | simple HTTP-based pub-sub" />
|
||||
<meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
|
||||
<meta property="og:image" content="/static/img/ntfy.png" />
|
||||
<meta property="og:url" content="https://ntfy.sh" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<h1>ntfy.sh</h1>
|
||||
|
||||
<h1>ntfy.sh - simple HTTP-based pub-sub</h1>
|
||||
<p>
|
||||
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based pub-sub notification service. It allows you to send desktop and (soon) phone notifications
|
||||
via scripts, without signup or cost. It's entirely free and open source. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>.
|
||||
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based pub-sub notification service and tool.
|
||||
It allows you to send <b>desktop notifications via scripts from any computer</b>, entirely <b>without signup or cost</b>.
|
||||
It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can subscribe to a topic either in this web UI, or in your own app by subscribing to an SSE/EventSource
|
||||
or JSON feed. Once subscribed, you can publish messages via PUT or POST.
|
||||
</p>
|
||||
|
||||
<p id="error"></p>
|
||||
|
||||
<h2>Subscribe to a topic</h2>
|
||||
<p>
|
||||
Topics are created on the fly by subscribing to them. You can create and subscribe to a topic either in this web UI, or in
|
||||
your own app by subscribing to an <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>,
|
||||
a JSON feed, or raw feed.
|
||||
</p>
|
||||
<p>
|
||||
Because there is no sign-up, <b>the topic is essentially a password</b>, so pick something that's not easily guessable.
|
||||
</p>
|
||||
|
||||
<h3>Subscribe via web</h3>
|
||||
<p>
|
||||
If you subscribe to a topic via this web UI in the field below, messages published to any subscribed topic
|
||||
will show up as <b>desktop notification</b>.
|
||||
</p>
|
||||
<form id="subscribeForm">
|
||||
<p>
|
||||
<input type="text" id="topicField" size="64" placeholder="Topic ID (letters, numbers, _ and -)" pattern="[-_A-Za-z]{1,64}" autofocus />
|
||||
<input type="submit" id="subscribeButton" value="Subscribe topic" />
|
||||
<label for="topicField">Topic ID:</label>
|
||||
<input type="text" id="topicField" placeholder="Letters, numbers, _ and -" pattern="[-_A-Za-z]{1,64}" autofocus />
|
||||
<input type="submit" id="subscribeButton" value="Subscribe" />
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p id="topicsHeader"><b>Subscribed topics:</b></p>
|
||||
<p id="topicsHeader">Subscribed topics:</p>
|
||||
<ul id="topicsList"></ul>
|
||||
|
||||
<h3>Subscribe via your app, or via the CLI</h3>
|
||||
<code>
|
||||
curl -s ntfy.sh/mytopic/raw # one message per line (\n are replaced with a space)<br/>
|
||||
curl -s ntfy.sh/mytopic/json # one JSON message per line<br/>
|
||||
curl -s ntfy.sh/mytopic/sse # server-sent events (SSE) stream
|
||||
</code>
|
||||
|
||||
<h2>Publishing messages</h2>
|
||||
<p>
|
||||
Publishing messages can be done via PUT or POST using. Here's an example using <tt>curl</tt>:
|
||||
</p>
|
||||
<code>
|
||||
curl -d "long process is done" ntfy.sh/mytopic
|
||||
</code>
|
||||
<p>
|
||||
Messages published to a non-existing topic or a topic without subscribers will not be delivered later.
|
||||
There is (currently) no buffering of any kind. If you're not listening, the message won't be delivered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
let topics = {};
|
||||
|
||||
const topicsHeader = document.getElementById("topicsHeader");
|
||||
const topicsList = document.getElementById("topicsList");
|
||||
const topicField = document.getElementById("topicField");
|
||||
const subscribeButton = document.getElementById("subscribeButton");
|
||||
const subscribeForm = document.getElementById("subscribeForm");
|
||||
const errorField = document.getElementById("error");
|
||||
|
||||
const subscribe = (topic) => {
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
subscribeInternal(topic, 0);
|
||||
} else {
|
||||
showNotificationDeniedError();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
subscribeInternal(topic, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeInternal = (topic, delaySec) => {
|
||||
setTimeout(() => {
|
||||
// Render list entry
|
||||
let topicEntry = document.getElementById(`topic-${topic}`);
|
||||
if (!topicEntry) {
|
||||
topicEntry = document.createElement('li');
|
||||
topicEntry.id = `topic-${topic}`;
|
||||
topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||
topicsList.appendChild(topicEntry);
|
||||
}
|
||||
topicsHeader.style.display = '';
|
||||
|
||||
// Open event source
|
||||
let eventSource = new EventSource(`${topic}/sse`);
|
||||
eventSource.onopen = () => {
|
||||
topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||
delaySec = 0; // Reset on successful connection
|
||||
};
|
||||
eventSource.onerror = (e) => {
|
||||
const newDelaySec = (delaySec + 5 <= 30) ? delaySec + 5 : 30;
|
||||
topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||
eventSource.close()
|
||||
subscribeInternal(topic, newDelaySec);
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
const event = JSON.parse(e.data);
|
||||
new Notification(event.message);
|
||||
};
|
||||
topics[topic] = eventSource;
|
||||
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
|
||||
}, delaySec * 1000);
|
||||
};
|
||||
|
||||
const unsubscribe = (topic) => {
|
||||
topics[topic].close();
|
||||
delete topics[topic];
|
||||
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
|
||||
document.getElementById(`topic-${topic}`).remove();
|
||||
if (Object.keys(topics).length === 0) {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
errorField.innerHTML = msg;
|
||||
topicField.disabled = true;
|
||||
subscribeButton.disabled = true;
|
||||
};
|
||||
|
||||
const showBrowserIncompatibleError = () => {
|
||||
showError("Your browser is not compatible to use the web-based desktop notifications.");
|
||||
};
|
||||
|
||||
const showNotificationDeniedError = () => {
|
||||
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
|
||||
};
|
||||
|
||||
subscribeForm.onsubmit = function () {
|
||||
if (!topicField.value) {
|
||||
return false;
|
||||
}
|
||||
subscribe(topicField.value);
|
||||
topicField.value = "";
|
||||
return false;
|
||||
};
|
||||
|
||||
// Disable Web UI if notifications of EventSource are not available
|
||||
if (!window["Notification"] || !window["EventSource"]) {
|
||||
showBrowserIncompatibleError();
|
||||
} else if (Notification.permission === "denied") {
|
||||
showNotificationDeniedError();
|
||||
}
|
||||
|
||||
// Reset UI
|
||||
topicField.value = "";
|
||||
|
||||
// Restore topics
|
||||
const storedTopics = localStorage.getItem('topics');
|
||||
if (storedTopics && Notification.permission === "granted") {
|
||||
const storedTopicsArray = JSON.parse(storedTopics)
|
||||
storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); });
|
||||
if (storedTopicsArray.length === 0) {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
149
server/server.go
149
server/server.go
@@ -2,13 +2,15 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
_ "embed" // required for go:embed
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/config"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -16,19 +18,33 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is the main server
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
topics map[string]*topic
|
||||
mu sync.Mutex
|
||||
config *config.Config
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Time int64 `json:"time"`
|
||||
Message string `json:"message"`
|
||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
seen time.Time
|
||||
}
|
||||
|
||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||
type errHTTP struct {
|
||||
Code int
|
||||
Status string
|
||||
}
|
||||
|
||||
func (e errHTTP) Error() string {
|
||||
return fmt.Sprintf("http: %s", e.Status)
|
||||
}
|
||||
|
||||
const (
|
||||
messageLimit = 1024
|
||||
messageLimit = 1024
|
||||
visitorExpungeAfter = 30 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,22 +52,34 @@ var (
|
||||
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
|
||||
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`)
|
||||
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`)
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
|
||||
//go:embed "index.html"
|
||||
indexSource string
|
||||
|
||||
errTopicNotFound = errors.New("topic not found")
|
||||
//go:embed static
|
||||
webStaticFs embed.FS
|
||||
|
||||
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
|
||||
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
|
||||
)
|
||||
|
||||
func New(conf *config.Config) *Server {
|
||||
return &Server{
|
||||
config: conf,
|
||||
topics: make(map[string]*topic),
|
||||
config: conf,
|
||||
topics: make(map[string]*topic),
|
||||
visitors: make(map[string]*visitor),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
go s.runMonitor()
|
||||
go func() {
|
||||
ticker := time.NewTicker(s.config.ManagerInterval)
|
||||
for {
|
||||
<-ticker.C
|
||||
s.updateStatsAndExpire()
|
||||
}
|
||||
}()
|
||||
return s.listenAndServe()
|
||||
}
|
||||
|
||||
@@ -61,31 +89,47 @@ func (s *Server) listenAndServe() error {
|
||||
return http.ListenAndServe(s.config.ListenHTTP, nil)
|
||||
}
|
||||
|
||||
func (s *Server) runMonitor() {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
s.mu.Lock()
|
||||
var subscribers, messages int
|
||||
for _, t := range s.topics {
|
||||
subs, msgs := t.Stats()
|
||||
subscribers += subs
|
||||
messages += msgs
|
||||
func (s *Server) updateStatsAndExpire() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Expire visitors from rate visitors map
|
||||
for ip, v := range s.visitors {
|
||||
if time.Since(v.seen) > visitorExpungeAfter {
|
||||
delete(s.visitors, ip)
|
||||
}
|
||||
log.Printf("Stats: %d topic(s), %d subscriber(s), %d message(s) sent", len(s.topics), subscribers, messages)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Print stats
|
||||
var subscribers, messages int
|
||||
for _, t := range s.topics {
|
||||
subs, msgs := t.Stats()
|
||||
subscribers += subs
|
||||
messages += msgs
|
||||
}
|
||||
log.Printf("Stats: %d topic(s), %d subscriber(s), %d message(s) sent, %d visitor(s)",
|
||||
len(s.topics), subscribers, messages, len(s.visitors))
|
||||
}
|
||||
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.handleInternal(w, r); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, err.Error()+"\n")
|
||||
if e, ok := err.(*errHTTP); ok {
|
||||
s.fail(w, r, e.Code, e)
|
||||
} else {
|
||||
s.fail(w, r, http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
v := s.visitor(r.RemoteAddr)
|
||||
if !v.limiter.Allow() {
|
||||
return errHTTPTooManyRequests
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
||||
return s.handleHome(w, r)
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.handleStatic(w, r)
|
||||
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
|
||||
return s.handleSubscribeJSON(w, r)
|
||||
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
|
||||
@@ -94,9 +138,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.handleSubscribeRaw(w, r)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
|
||||
return s.handlePublishHTTP(w, r)
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.handleOptions(w, r)
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return nil
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
||||
@@ -118,7 +163,11 @@ func (s *Server) handlePublishHTTP(w http.ResponseWriter, r *http.Request) error
|
||||
Time: time.Now().UnixMilli(),
|
||||
Message: string(b),
|
||||
}
|
||||
return t.Publish(msg)
|
||||
if err := t.Publish(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) error {
|
||||
@@ -133,6 +182,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) err
|
||||
return nil
|
||||
})
|
||||
defer s.unsubscribe(t, subscriberID)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
case <-r.Context().Done():
|
||||
@@ -158,7 +208,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro
|
||||
})
|
||||
defer s.unsubscribe(t, subscriberID)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
if _, err := io.WriteString(w, "event: open\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -192,6 +242,17 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||
http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createTopic(id string) *topic {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -206,7 +267,7 @@ func (s *Server) topic(topicID string) (*topic, error) {
|
||||
defer s.mu.Unlock()
|
||||
c, ok := s.topics[topicID]
|
||||
if !ok {
|
||||
return nil, errTopicNotFound
|
||||
return nil, errHTTPNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -218,3 +279,31 @@ func (s *Server) unsubscribe(t *topic, subscriberID int) {
|
||||
delete(s.topics, t.id)
|
||||
}
|
||||
}
|
||||
|
||||
// visitor creates or retrieves a rate.Limiter for the given visitor.
|
||||
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
|
||||
func (s *Server) visitor(remoteAddr string) *visitor {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
ip = remoteAddr // This should not happen in real life; only in tests.
|
||||
}
|
||||
v, exists := s.visitors[ip]
|
||||
if !exists {
|
||||
v = &visitor{
|
||||
rate.NewLimiter(s.config.Limit, s.config.LimitBurst),
|
||||
time.Now(),
|
||||
}
|
||||
s.visitors[ip] = v
|
||||
return v
|
||||
}
|
||||
v.seen = time.Now()
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, err error) {
|
||||
log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, code, err.Error())
|
||||
w.WriteHeader(code)
|
||||
io.WriteString(w, fmt.Sprintf("%s\n", http.StatusText(code)))
|
||||
}
|
||||
|
||||
76
server/static/css/app.css
Normal file
76
server/static/css/app.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/* general styling */
|
||||
|
||||
html, body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #39005a;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
tt {
|
||||
background: #eee;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
background: #eee;
|
||||
font-family: monospace;
|
||||
padding: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Lato font (OFL), https://fonts.google.com/specimen/Lato#about,
|
||||
embedded with the help of https://google-webfonts-helper.herokuapp.com/fonts/lato?subsets=latin */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(''),
|
||||
url('../font/lato-v17-latin-ext_latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../font/lato-v17-latin-ext_latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* Main page */
|
||||
|
||||
#main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 50px auto;
|
||||
}
|
||||
|
||||
#error {
|
||||
color: darkred;
|
||||
font-style: italic;
|
||||
}
|
||||
BIN
server/static/font/lato-v17-latin-ext_latin-regular.woff
Normal file
BIN
server/static/font/lato-v17-latin-ext_latin-regular.woff
Normal file
Binary file not shown.
BIN
server/static/font/lato-v17-latin-ext_latin-regular.woff2
Normal file
BIN
server/static/font/lato-v17-latin-ext_latin-regular.woff2
Normal file
Binary file not shown.
BIN
server/static/img/favicon.png
Normal file
BIN
server/static/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
server/static/img/ntfy.png
Normal file
BIN
server/static/img/ntfy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
128
server/static/js/app.js
Normal file
128
server/static/js/app.js
Normal file
@@ -0,0 +1,128 @@
|
||||
|
||||
/**
|
||||
* Hello, dear curious visitor. I am not a web-guy, so please don't judge my horrible JS code.
|
||||
* In fact, please do tell me about all the things I did wrong and that I could improve. I've been trying
|
||||
* to read up on modern JS, but it's just a little much.
|
||||
*
|
||||
* Feel free to open tickets at https://github.com/binwiederhier/ntfy/issues. Thank you!
|
||||
*/
|
||||
|
||||
/* All the things */
|
||||
|
||||
let topics = {};
|
||||
|
||||
const topicsHeader = document.getElementById("topicsHeader");
|
||||
const topicsList = document.getElementById("topicsList");
|
||||
const topicField = document.getElementById("topicField");
|
||||
const subscribeButton = document.getElementById("subscribeButton");
|
||||
const subscribeForm = document.getElementById("subscribeForm");
|
||||
const errorField = document.getElementById("error");
|
||||
|
||||
const subscribe = (topic) => {
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
subscribeInternal(topic, 0);
|
||||
} else {
|
||||
showNotificationDeniedError();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
subscribeInternal(topic, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeInternal = (topic, delaySec) => {
|
||||
setTimeout(() => {
|
||||
// Render list entry
|
||||
let topicEntry = document.getElementById(`topic-${topic}`);
|
||||
if (!topicEntry) {
|
||||
topicEntry = document.createElement('li');
|
||||
topicEntry.id = `topic-${topic}`;
|
||||
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||
topicsList.appendChild(topicEntry);
|
||||
}
|
||||
topicsHeader.style.display = '';
|
||||
|
||||
// Open event source
|
||||
let eventSource = new EventSource(`${topic}/sse`);
|
||||
eventSource.onopen = () => {
|
||||
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}')">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||
delaySec = 0; // Reset on successful connection
|
||||
};
|
||||
eventSource.onerror = (e) => {
|
||||
const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
|
||||
topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||
eventSource.close()
|
||||
subscribeInternal(topic, newDelaySec);
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
const event = JSON.parse(e.data);
|
||||
new Notification(event.message);
|
||||
};
|
||||
topics[topic] = eventSource;
|
||||
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
|
||||
}, delaySec * 1000);
|
||||
};
|
||||
|
||||
const unsubscribe = (topic) => {
|
||||
topics[topic].close();
|
||||
delete topics[topic];
|
||||
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
|
||||
document.getElementById(`topic-${topic}`).remove();
|
||||
if (Object.keys(topics).length === 0) {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const test = (topic) => {
|
||||
fetch(`/${topic}`, {
|
||||
method: 'PUT',
|
||||
body: `This is a test notification for topic ${topic}!`
|
||||
})
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
errorField.innerHTML = msg;
|
||||
topicField.disabled = true;
|
||||
subscribeButton.disabled = true;
|
||||
};
|
||||
|
||||
const showBrowserIncompatibleError = () => {
|
||||
showError("Your browser is not compatible to use the web-based desktop notifications.");
|
||||
};
|
||||
|
||||
const showNotificationDeniedError = () => {
|
||||
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
|
||||
};
|
||||
|
||||
subscribeForm.onsubmit = function () {
|
||||
if (!topicField.value) {
|
||||
return false;
|
||||
}
|
||||
subscribe(topicField.value);
|
||||
topicField.value = "";
|
||||
return false;
|
||||
};
|
||||
|
||||
// Disable Web UI if notifications of EventSource are not available
|
||||
if (!window["Notification"] || !window["EventSource"]) {
|
||||
showBrowserIncompatibleError();
|
||||
} else if (Notification.permission === "denied") {
|
||||
showNotificationDeniedError();
|
||||
}
|
||||
|
||||
// Reset UI
|
||||
topicField.value = "";
|
||||
|
||||
// Restore topics
|
||||
const storedTopics = localStorage.getItem('topics');
|
||||
if (storedTopics && Notification.permission === "granted") {
|
||||
const storedTopicsArray = JSON.parse(storedTopics)
|
||||
storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); });
|
||||
if (storedTopicsArray.length === 0) {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// topic represents a channel to which subscribers can subscribe, and publishers
|
||||
// can publish a message
|
||||
type topic struct {
|
||||
id string
|
||||
subscribers map[int]subscriber
|
||||
@@ -19,6 +21,13 @@ type topic struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// message represents a message published to a topic
|
||||
type message struct {
|
||||
Time int64 `json:"time"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// subscriber is a function that is called for every new message on a topic
|
||||
type subscriber func(msg *message) error
|
||||
|
||||
func newTopic(id string) *topic {
|
||||
|
||||
Reference in New Issue
Block a user