Compare commits

...

8 Commits

Author SHA1 Message Date
Philipp Heckel
317621c696 Styling 2021-10-24 14:22:53 -04:00
Philipp Heckel
39574c954b Examples, CORS 2021-10-24 13:34:15 -04:00
Philipp Heckel
1ab0282101 Test button 2021-10-24 09:12:29 -04:00
Philipp Heckel
d6a7c3f5b0 Derp 2021-10-23 23:39:41 -04:00
Philipp Heckel
d9e2e11a0b Update FAQ 2021-10-23 23:39:14 -04:00
Philipp Heckel
d9b9a62048 Build and install instructions 2021-10-23 23:37:30 -04:00
Philipp Heckel
c3b97365ce Text field for mobile 2021-10-23 23:30:10 -04:00
Philipp Heckel
23cf77e0b7 Rate limiting, docs 2021-10-23 22:49:50 -04:00
15 changed files with 549 additions and 178 deletions

View File

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

Binary file not shown.

View File

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

View 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
View File

@@ -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
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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
View 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';
}

View File

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