Compare commits

..

11 Commits

Author SHA1 Message Date
Philipp Heckel
92f48fbbea Bump version 2021-12-15 19:24:38 -05:00
Philipp Heckel
200dd25ffa Add limitations section 2021-12-15 19:23:44 -05:00
Philipp Heckel
534b93e142 Webhooks (#55), more tests (#35) and python examples (#50) 2021-12-15 16:12:40 -05:00
Philipp Heckel
02f8a32b46 GET-based send/trigger, relates to #55 2021-12-15 09:41:55 -05:00
Philipp Heckel
9cb48dbb60 Move background tasks to functions 2021-12-15 09:13:16 -05:00
Philipp Heckel
bd09fb4c54 Bump version 2021-12-13 22:35:54 -05:00
Philipp Heckel
63206f8581 Firebase keepalive, supports #56 2021-12-13 22:30:28 -05:00
Philipp Heckel
de0c41ec3b Resize/compress images 2021-12-12 20:37:38 -05:00
Philipp Heckel
eaefb436d6 More docs 2021-12-12 14:26:24 -05:00
Philipp Heckel
5843de5dfc Documentation for the intent stuff 2021-12-12 09:26:35 -05:00
Philipp Heckel
6abda93a14 Bump version 2021-12-11 07:30:48 -05:00
48 changed files with 686 additions and 98 deletions

1
.gitignore vendored
View File

@@ -2,4 +2,5 @@ dist/
build/
.idea/
server/docs/
tools/fbsend/fbsend
*.iml

View File

@@ -50,7 +50,7 @@ docs: docs-deps
check: test fmt-check vet lint staticcheck
test: .PHONY
$(GO) test ./...
$(GO) test -v ./...
race: .PHONY
$(GO) test -race ./...

View File

@@ -7,14 +7,15 @@ import (
// Defines default config settings
const (
DefaultListenHTTP = ":80"
DefaultCacheDuration = 12 * time.Hour
DefaultKeepaliveInterval = 30 * time.Second
DefaultManagerInterval = time.Minute
DefaultAtSenderInterval = 10 * time.Second
DefaultMinDelay = 10 * time.Second
DefaultMaxDelay = 3 * 24 * time.Hour
DefaultMessageLimit = 512
DefaultListenHTTP = ":80"
DefaultCacheDuration = 12 * time.Hour
DefaultKeepaliveInterval = 30 * time.Second
DefaultManagerInterval = time.Minute
DefaultAtSenderInterval = 10 * time.Second
DefaultMinDelay = 10 * time.Second
DefaultMaxDelay = 3 * 24 * time.Hour
DefaultMessageLimit = 512
DefaultFirebaseKeepaliveInterval = time.Hour
)
// Defines all the limits
@@ -40,6 +41,7 @@ type Config struct {
KeepaliveInterval time.Duration
ManagerInterval time.Duration
AtSenderInterval time.Duration
FirebaseKeepaliveInterval time.Duration
MessageLimit int
MinDelay time.Duration
MaxDelay time.Duration
@@ -66,6 +68,7 @@ func New(listenHTTP string) *Config {
MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay,
AtSenderInterval: DefaultAtSenderInterval,
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
GlobalTopicLimit: DefaultGlobalTopicLimit,
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,

View File

@@ -66,7 +66,7 @@ is in the request body. Here's an example showing how to publish a simple messag
This will create a notification that looks like this:
<figure markdown>
![basic notification](static/img/basic-notification.png){ width=500 }
![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }
<figcaption>Android notification</figcaption>
</figure>
@@ -76,7 +76,7 @@ That's it. You're all set. Go play and read the rest of the docs. I highly recom
Here's another video showing the entire process:
<figure>
<video controls muted autoplay loop width="650" src="static/img/overview.mp4"></video>
<video controls muted autoplay loop width="650" src="static/img/android-video-overview.mp4"></video>
<figcaption>Sending push notifications to your Android phone</figcaption>
</figure>

View File

@@ -20,21 +20,21 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_x86_64.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_armv7.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_armv7.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_arm64.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_arm64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy
```
@@ -82,7 +82,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -90,7 +90,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -98,7 +98,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -108,21 +108,21 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.5.3/ntfy_1.5.3_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.7.0/ntfy_1.7.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```

View File

@@ -30,6 +30,12 @@ Here's an example showing how to publish a simple message using a POST request:
strings.NewReader("Backup successful 😀"))
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="Backup successful 😀".encode(encoding='utf-8'))
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -44,7 +50,7 @@ Here's an example showing how to publish a simple message using a POST request:
If you have the [Android app](subscribe/phone.md) installed on your phone, this will create a notification that looks like this:
<figure markdown>
![basic notification](static/img/basic-notification.png){ width=500 }
![basic notification](static/img/android-screenshot-basic-notification.png){ width=500 }
<figcaption>Android notification</figcaption>
</figure>
@@ -95,6 +101,17 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/phil_alerts",
data="Remote access to phils-laptop detected. Act right away.",
headers={
"Title": "Unauthorized access detected",
"Priority": "urgent",
"Tags": "warning,skull"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([
@@ -151,6 +168,13 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/controversial",
data="Oh my ...",
headers={ "Title": "Dogs are better than cats" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/controversial', false, stream_context_create([
@@ -217,6 +241,13 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/phil_alerts",
data="An urgent message",
headers={ "Priority": "5" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/phil_alerts', false, stream_context_create([
@@ -314,6 +345,13 @@ them with a comma, e.g. `tag1,tag2,tag3`.
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/backups",
data="Backup of mailsrv13 failed",
headers={ "Tags": "warning,mailsrv13,daily-backup" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
@@ -382,6 +420,13 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/hello",
data="Good morning",
headers={ "At": "tomorrow, 10am" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
@@ -397,7 +442,6 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**):
<table class="remove-md-box"><tr>
<td>
<table><thead><tr><th><code>Delay/At/In</code> header</th><th>Message will be delivered at</th><th>Explanation</th></tr></thead><tbody>
@@ -411,6 +455,87 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
</td>
</tr></table>
## Webhooks (Send via GET)
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without
any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are
also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all
[supported parameters and headers](#list-of-all-parameters) for details.
For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message
(aka trigger the webhook):
=== "Command line (curl)"
```
curl ntfy.sh/mywebhook/trigger
```
=== "HTTP"
``` http
GET /mywebhook/trigger HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/mywebhook/trigger')
```
=== "Go"
``` go
http.Get("https://ntfy.sh/mywebhook/trigger")
```
=== "Python"
``` python
requests.get("https://ntfy.sh/mywebhook/trigger")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mywebhook/trigger');
```
To add a custom message, simply append the `message=` URL parameter. And of course you can set the
[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well.
For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters).
Here's an example with a custom message, tags and a priority:
=== "Command line (curl)"
```
curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
```
=== "HTTP"
``` http
GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull')
```
=== "Go"
``` go
http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
```
=== "Python"
``` python
requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
```
## Advanced features
### Message caching
@@ -459,6 +584,13 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="This message won't be stored server-side",
headers={ "Cache": "no" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -517,6 +649,13 @@ to `no`. This will instruct the server not to forward messages to Firebase.
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
data="This message won't be forwarded to FCM",
headers={ "Firebase": "no" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
@@ -529,3 +668,28 @@ to `no`. This will instruct the server not to forward messages to Firebase.
]
]));
```
## Limitations
There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into,
but just in case, let's list them all:
| Limit | Description |
|---|---|
| **Message length** | Each message can be up to 512 bytes long. Longer messages are truncated. |
| **Requests per second** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
| **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Total number of topics** | By default, the server is configured to allow 5,000 topics. The ntfy.sh server has higher limits though. |
## List of all parameters
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
and can be passed as **HTTP headers** or **query parameters in the URL**.
| Parameter | Aliases (case-insensitive) | Description |
|---|---|---|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

View File

@@ -3,7 +3,6 @@ You can use the [ntfy Android App](https://play.google.com/store/apps/details?id
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4).
## Android
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
@@ -11,26 +10,27 @@ You can get the Android app from both [Google Play](https://play.google.com/stor
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
the F-Droid flavor does not use Firebase.
### Overview
## Overview
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
<div id="android-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-main.jpg"><img src="../../static/img/android-screenshot-main.jpg"/></a>
<a href="../../static/img/android-screenshot-detail.jpg"><img src="../../static/img/android-screenshot-detail.jpg"/></a>
<a href="../../static/img/android-screenshot-add.jpg"><img src="../../static/img/android-screenshot-add.jpg"/></a>
<a href="../../static/img/android-screenshot-add-instant.jpg"><img src="../../static/img/android-screenshot-add-instant.jpg"/></a>
<a href="../../static/img/android-screenshot-add-other.jpg"><img src="../../static/img/android-screenshot-add-other.jpg"/></a>
<a href="../../static/img/android-screenshot-main.png"><img src="../../static/img/android-screenshot-main.png"/></a>
<a href="../../static/img/android-screenshot-detail.png"><img src="../../static/img/android-screenshot-detail.png"/></a>
<a href="../../static/img/android-screenshot-pause.png"><img src="../../static/img/android-screenshot-pause.png"/></a>
<a href="../../static/img/android-screenshot-add.png"><img src="../../static/img/android-screenshot-add.png"/></a>
<a href="../../static/img/android-screenshot-add-instant.png"><img src="../../static/img/android-screenshot-add-instant.png"/></a>
<a href="../../static/img/android-screenshot-add-other.png"><img src="../../static/img/android-screenshot-add-other.png"/></a>
</div>
If those screenshots are still not enough, here's a video:
<figure>
<video controls muted autoplay loop width="650" src="../../static/img/overview.mp4"></video>
<video controls muted autoplay loop width="650" src="../../static/img/android-video-overview.mp4"></video>
<figcaption>Sending push notifications to your Android phone</figcaption>
</figure>
### Message priority
## Message priority
When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
@@ -50,7 +50,7 @@ the settings (and custom sounds or vibration) for each of the priorities:
<figcaption>Per-priority sound/vibration settings</figcaption>
</figure>
### Instant delivery
## Instant delivery
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
you'll see as a permanent notification that looks like this:
@@ -69,8 +69,8 @@ To do so, long-press on the foreground notification (screenshot above) and navig
<figcaption>Turning off the persistent instant delivery notification</figcaption>
</figure>
### Limitations without instant delivery
Without instant delivery, **messages may arrive with a significant delay** (sometimes many minutes, or even hours later). If you've ever picked up your phone and
**Limitations without instant delivery**: Without instant delivery, **messages may arrive with a significant delay**
(sometimes many minutes, or even hours later). If you've ever picked up your phone and
suddenly had 10 messages that were sent long before you know what I'm talking about.
The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging). FCM is the
@@ -80,6 +80,82 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
## Integrations
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
**react to incoming messages**, as well as **send messages**.
### React to incoming messages
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
broadcasts is supported:
<div id="integration-screenshots-receive" class="screenshots">
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
<a href="../../static/img/android-screenshot-tasker-action-edit.png"><img src="../../static/img/android-screenshot-tasker-action-edit.png"/></a>
</div>
For MacroDroid, be sure to type in the package name `io.heckel.ntfy`, otherwise intents may be silently swallowed.
If you're using topics to drive automation, you'll likely want to mute the topic in the ntfy app. This will prevent
notification popups:
<figure markdown>
![muted subscription](../static/img/android-screenshot-muted.png){ width=500 }
<figcaption>Muting notifications to prevent popups</figcaption>
</figure>
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
| Extra name | Type | Example | Description |
|---|---|---|---|
| `id` | *string* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
| `base_url` | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
| `topic` ❤️ | *string* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
| `muted` | *bool* | `true` | Indicates whether the subscription was muted in the app |
| `muted_str` | *string (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
| `time` | *int* | `1635528741` | Message date time, as Unix time stamp |
| `title` | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
| `message` ❤️ | *string* | `Some message` | Message body; **this is likely what you're interested in** |
| `tags` | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
### Send messages using intents
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can
broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP
POST request to [publish a message](../publish.md). This is primarily useful for apps that do not support HTTP POST/PUT
(like MacroDroid). In Tasker, you can simply use the "HTTP Request" action, which is a little easier and also works if
ntfy is not installed.
Here's what that looks like:
<div id="integration-screenshots-send" class="screenshots">
<a href="../../static/img/android-screenshot-macrodroid-send-macro.png"><img src="../../static/img/android-screenshot-macrodroid-send-macro.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-send-action.png"><img src="../../static/img/android-screenshot-macrodroid-send-action.png"/></a>
<a href="../../static/img/android-screenshot-tasker-profile-send.png"><img src="../../static/img/android-screenshot-tasker-profile-send.png"/></a>
<a href="../../static/img/android-screenshot-tasker-task-edit-post.png"><img src="../../static/img/android-screenshot-tasker-task-edit-post.png"/></a>
<a href="../../static/img/android-screenshot-tasker-action-http-post.png"><img src="../../static/img/android-screenshot-tasker-action-http-post.png"/></a>
</div>
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
| Extra name | Required | Type | Example | Description |
|---|---|---|---|---|
| `base_url` | - | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
| `topic` ❤️ | ✔ | *string* | `mytopic` | Topic name; **you must set this** |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
| `message` ❤️ | ✔ | *string* | `Some message` | Message body; **you must set this** |
| `tags` | - | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `priority` | - | *string or int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
## iPhone/iOS
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
for ntfy, but it's in the works. You can track the status on GitHub.

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
import requests
resp = requests.get("https://ntfy.sh/mytopic/trigger",
data="Backup successful 😀".encode(encoding='utf-8'),
headers={
"Priority": "high",
"Tags": "warning,skull",
"Title": "Hello there"
})
resp.raise_for_status()

10
go.mod
View File

@@ -2,8 +2,6 @@ module heckel.io/ntfy
go 1.17
replace github.com/olebedev/when => github.com/binwiederhier/when v0.0.1-binwiederhier2
require (
cloud.google.com/go/firestore v1.6.1 // indirect
cloud.google.com/go/storage v1.18.2 // indirect
@@ -11,12 +9,12 @@ require (
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/mattn/go-sqlite3 v1.14.9
github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.3.0
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.62.0
google.golang.org/api v0.63.0
gopkg.in/yaml.v2 v2.4.0 // indirect
)
@@ -39,12 +37,12 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
google.golang.org/grpc v1.42.0 // indirect
google.golang.org/grpc v1.43.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

19
go.sum
View File

@@ -25,7 +25,6 @@ cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aD
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -60,8 +59,6 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/binwiederhier/when v0.0.1-binwiederhier2 h1:BjQC7OQI4MK0vXeltn2BEuf0Tdh/M6YNh1JrepnVr2I=
github.com/binwiederhier/when v0.0.1-binwiederhier2/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -204,6 +201,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -400,8 +399,8 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -506,8 +505,8 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.62.0 h1:PhGymJMXfGBzc4lBRmrx9+1w4w2wEzURHNGF/sD/xGc=
google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
google.golang.org/api v0.63.0 h1:n2bqqK895ygnBpdPDYetfy23K7fJ22wsrZKCyfuRkkA=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -577,8 +576,6 @@ google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
@@ -608,8 +605,8 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

View File

@@ -76,6 +76,7 @@ var (
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$`)
sendRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
@@ -105,6 +106,10 @@ var (
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
)
const (
firebaseControlTopic = "~control" // See Android if changed
)
// New instantiates a new Server. It creates the cache and adds a Firebase
// subscriber (if configured).
func New(conf *config.Config) (*Server, error) {
@@ -152,9 +157,17 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
return nil, err
}
return func(m *message) error {
_, err := msg.Send(context.Background(), &messaging.Message{
Topic: m.Topic,
Data: map[string]string{
var data map[string]string // Matches https://ntfy.sh/docs/subscribe/api/#json-message-format
switch m.Event {
case keepaliveEvent, openEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
}
case messageEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
@@ -163,7 +176,11 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
"tags": strings.Join(m.Tags, ","),
"title": m.Title,
"message": m.Message,
},
}
}
_, err := msg.Send(context.Background(), &messaging.Message{
Topic: m.Topic,
Data: data,
})
return err
}, nil
@@ -172,28 +189,14 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
// a manager go routine to print stats and prune messages.
func (s *Server) Run() error {
go func() {
ticker := time.NewTicker(s.config.ManagerInterval)
for {
<-ticker.C
s.updateStatsAndPrune()
}
}()
go func() {
ticker := time.NewTicker(s.config.AtSenderInterval)
for {
<-ticker.C
if err := s.sendDelayedMessages(); err != nil {
log.Printf("error sending scheduled messages: %s", err.Error())
}
}
}()
go s.runManager()
go s.runAtSender()
go s.runFirebaseKeepliver()
listenStr := fmt.Sprintf("%s/http", s.config.ListenHTTP)
if s.config.ListenHTTPS != "" {
listenStr += fmt.Sprintf(" %s/https", s.config.ListenHTTPS)
}
log.Printf("Listening on %s", listenStr)
http.HandleFunc("/", s.handle)
errChan := make(chan error)
go func() {
@@ -234,6 +237,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
return s.handleHome(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
} else if r.Method == http.MethodGet && sendRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeJSON)
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
@@ -271,7 +276,7 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visitor) error {
t, err := s.topicFromID(r.URL.Path[1:])
t, err := s.topicFromPath(r.URL.Path)
if err != nil {
return err
}
@@ -280,14 +285,14 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
if err != nil {
return err
}
m := newDefaultMessage(t.ID, string(b))
if m.Message == "" {
return errHTTPBadRequest
}
cache, firebase, err := s.parseHeaders(r.Header, m)
m := newDefaultMessage(t.ID, strings.TrimSpace(string(b)))
cache, firebase, err := s.parseParams(r, m)
if err != nil {
return err
}
if m.Message == "" {
m.Message = "triggered"
}
delayed := m.Time > time.Now().Unix()
if !delayed {
if err := t.Publish(m); err != nil {
@@ -306,21 +311,24 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
return err
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(m); err != nil {
return err
}
s.mu.Lock()
s.messages++
s.mu.Unlock()
s.inc(&s.messages)
return nil
}
func (s *Server) parseHeaders(header http.Header, m *message) (cache bool, firebase bool, err error) {
cache = readHeader(header, "x-cache", "cache") != "no"
firebase = readHeader(header, "x-firebase", "firebase") != "no"
m.Title = readHeader(header, "x-title", "title", "ti", "t")
priorityStr := readHeader(header, "x-priority", "priority", "prio", "p")
func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase bool, err error) {
cache = readParam(r, "x-cache", "cache") != "no"
firebase = readParam(r, "x-firebase", "firebase") != "no"
m.Title = readParam(r, "x-title", "title", "ti", "t")
messageStr := readParam(r, "x-message", "message", "m")
if messageStr != "" {
m.Message = messageStr
}
priorityStr := readParam(r, "x-priority", "priority", "prio", "p")
if priorityStr != "" {
switch strings.ToLower(priorityStr) {
case "1", "min":
@@ -337,14 +345,14 @@ func (s *Server) parseHeaders(header http.Header, m *message) (cache bool, fireb
return false, false, errHTTPBadRequest
}
}
tagsStr := readHeader(header, "x-tags", "tag", "tags", "ta")
tagsStr := readParam(r, "x-tags", "tag", "tags", "ta")
if tagsStr != "" {
m.Tags = make([]string, 0)
for _, s := range strings.Split(tagsStr, ",") {
m.Tags = append(m.Tags, strings.TrimSpace(s))
}
}
delayStr := readHeader(header, "x-delay", "delay", "x-at", "at", "x-in", "in")
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, errHTTPBadRequest
@@ -362,9 +370,15 @@ func (s *Server) parseHeaders(header http.Header, m *message) (cache bool, fireb
return cache, firebase, nil
}
func readHeader(header http.Header, names ...string) string {
func readParam(r *http.Request, names ...string) string {
for _, name := range names {
value := header.Get(name)
value := r.Header.Get(name)
if value != "" {
return strings.TrimSpace(value)
}
}
for _, name := range names {
value := r.URL.Query().Get(strings.ToLower(name))
if value != "" {
return strings.TrimSpace(value)
}
@@ -518,8 +532,12 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
return nil
}
func (s *Server) topicFromID(id string) (*topic, error) {
topics, err := s.topicsFromIDs(id)
func (s *Server) topicFromPath(path string) (*topic, error) {
parts := strings.Split(path, "/")
if len(parts) < 2 {
return nil, errHTTPBadRequest
}
topics, err := s.topicsFromIDs(parts[1])
if err != nil {
return nil, err
}
@@ -584,6 +602,38 @@ func (s *Server) updateStatsAndPrune() {
s.messages, len(s.topics), subscribers, messages, len(s.visitors))
}
func (s *Server) runManager() {
func() {
ticker := time.NewTicker(s.config.ManagerInterval)
for {
<-ticker.C
s.updateStatsAndPrune()
}
}()
}
func (s *Server) runAtSender() {
ticker := time.NewTicker(s.config.AtSenderInterval)
for {
<-ticker.C
if err := s.sendDelayedMessages(); err != nil {
log.Printf("error sending scheduled messages: %s", err.Error())
}
}
}
func (s *Server) runFirebaseKeepliver() {
if s.firebase == nil {
return
}
ticker := time.NewTicker(s.config.FirebaseKeepaliveInterval)
for {
<-ticker.C
if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
log.Printf("error sending Firebase keepalive message: %s", err.Error())
}
}
}
func (s *Server) sendDelayedMessages() error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -640,6 +690,12 @@ func (s *Server) visitor(r *http.Request) *visitor {
return v
}
func (s *Server) inc(counter *int64) {
s.mu.Lock()
defer s.mu.Unlock()
*counter++
}
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)

View File

@@ -4,10 +4,12 @@ import (
"bufio"
"context"
"encoding/json"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/config"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -34,7 +36,7 @@ func TestServer_PublishAndPoll(t *testing.T) {
require.Equal(t, "my first message", messages[0].Message)
require.Equal(t, "my second\n\nmessage", messages[1].Message)
response = request(t, s, "GET", "/mytopic/sse?poll=1", "", nil)
response = request(t, s, "GET", "/mytopic/sse?poll=1&since=all", "", nil)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
require.Equal(t, 3, len(lines))
require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message)
@@ -132,6 +134,9 @@ func TestServer_StaticSites(t *testing.T) {
rr = request(t, s, "HEAD", "/", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "OPTIONS", "/", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "GET", "/does-not-exist.txt", "", nil)
require.Equal(t, 404, rr.Code)
@@ -150,6 +155,10 @@ func TestServer_StaticSites(t *testing.T) {
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`)
require.Contains(t, rr.Body.String(), `<script src=static/js/extra.js></script>`)
rr = request(t, s, "GET", "/example.html", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), "</html>")
}
func TestServer_PublishLargeMessage(t *testing.T) {
@@ -168,6 +177,34 @@ func TestServer_PublishLargeMessage(t *testing.T) {
require.Equal(t, truncated, messages[0].Message)
}
func TestServer_PublishPriority(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for prio := 1; prio <= 5; prio++ {
response := request(t, s, "GET", fmt.Sprintf("/mytopic/publish?priority=%d", prio), fmt.Sprintf("priority %d", prio), nil)
msg := toMessage(t, response.Body.String())
require.Equal(t, prio, msg.Priority)
}
response := request(t, s, "GET", "/mytopic/publish?priority=min", "test", nil)
require.Equal(t, 1, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil)
require.Equal(t, 2, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil)
require.Equal(t, 3, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil)
require.Equal(t, 4, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil)
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil)
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
}
func TestServer_PublishNoCache(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -182,6 +219,7 @@ func TestServer_PublishNoCache(t *testing.T) {
messages := toMessages(t, response.Body.String())
require.Empty(t, messages)
}
func TestServer_PublishAt(t *testing.T) {
c := newTestConfig(t)
c.MinDelay = time.Second
@@ -302,6 +340,59 @@ func TestServer_PublishWithNopCache(t *testing.T) {
require.Empty(t, messages)
}
func TestServer_PublishAndPollSince(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
request(t, s, "PUT", "/mytopic", "test 1", nil)
time.Sleep(1100 * time.Millisecond)
since := time.Now().Unix()
request(t, s, "PUT", "/mytopic", "test 2", nil)
response := request(t, s, "GET", fmt.Sprintf("/mytopic/json?poll=1&since=%d", since), "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "test 2", messages[0].Message)
}
func TestServer_PublishViaGET(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic/trigger", "", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "triggered", msg.Message)
response = request(t, s, "GET", "/mytopic/send?message=This+is+a+test&t=This+is+a+title&tags=skull&x-priority=5&delay=24h", "", nil)
msg = toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "This is a test", msg.Message)
require.Equal(t, "This is a title", msg.Title)
require.Equal(t, []string{"skull"}, msg.Tags)
require.Equal(t, 5, msg.Priority)
require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
}
func TestServer_PublishFirebase(t *testing.T) {
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
c := newTestConfig(t)
c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
s := newTestServer(t, c)
// Normal message
response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
// Keepalive message
require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
time.Sleep(500 * time.Millisecond) // Time for sends
}
func newTestConfig(t *testing.T) *config.Config {
conf := config.New(":80")
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
@@ -363,3 +454,15 @@ func toMessage(t *testing.T, s string) *message {
require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m))
return &m
}
func firebaseServiceAccountFile(t *testing.T) string {
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
filename := filepath.Join(t.TempDir(), "firebase.json")
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
return filename
}
t.SkipNow()
return ""
}

2
tools/fbsend/README.md Normal file
View File

@@ -0,0 +1,2 @@
# fbsend
fbsend is a tiny tool to send data messages to Firebase. It's only used for testing.

50
tools/fbsend/main.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"context"
firebase "firebase.google.com/go"
"firebase.google.com/go/messaging"
"flag"
"fmt"
"google.golang.org/api/option"
"os"
"strings"
)
func main() {
conffile := flag.String("config", "/etc/fbsend/fbsend.json", "config file")
flag.Parse()
if flag.NArg() < 2 {
fail("Syntax: fbsend [-config FILE] topic key=value ...")
}
topic := flag.Arg(0)
data := make(map[string]string)
for i := 1; i < flag.NArg(); i++ {
kv := strings.SplitN(flag.Arg(i), "=", 2)
if len(kv) != 2 {
fail(fmt.Sprintf("Invalid argument: %s (%v)", flag.Arg(i), kv))
}
data[kv[0]] = kv[1]
}
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(*conffile))
if err != nil {
fail(err.Error())
}
msg, err := fb.Messaging(context.Background())
if err != nil {
fail(err.Error())
}
_, err = msg.Send(context.Background(), &messaging.Message{
Topic: topic,
Data: data,
})
if err != nil {
fail(err.Error())
}
fmt.Println("Sent successfully")
}
func fail(s string) {
fmt.Println(s)
os.Exit(1)
}

1
util/embedfs/test.txt Normal file
View File

@@ -0,0 +1 @@
This is a test file for embedfs_test.go

44
util/embedfs_test.go Normal file
View File

@@ -0,0 +1,44 @@
package util
import (
"embed"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
"time"
)
var (
modTime = time.Now()
//go:embed embedfs
testFs embed.FS
testFsCached = &CachingEmbedFS{ModTime: modTime, FS: testFs}
)
func TestCachingEmbedFS(t *testing.T) {
s := http.FileServer(http.FS(testFsCached))
rr := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
s.ServeHTTP(rr, req)
require.Equal(t, 200, rr.Code)
lastModified := rr.Header().Get("Last-Modified")
rr = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/embedfs/test.txt", nil)
req.Header.Set("If-Modified-Since", lastModified)
s.ServeHTTP(rr, req)
require.Equal(t, 304, rr.Code) // Huzzah!
}
func TestCachingEmbedFS_Range(t *testing.T) {
s := http.FileServer(http.FS(testFsCached))
rr := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
req.Header.Set("Range", "bytes=1-20")
s.ServeHTTP(rr, req)
require.Equal(t, 206, rr.Code)
require.Equal(t, "his is a test file f", rr.Body.String())
}

View File

@@ -58,8 +58,3 @@ func (l *Limiter) Value() int64 {
defer l.mu.Unlock()
return l.value
}
// Limit returns the defined limit
func (l *Limiter) Limit() int64 {
return l.limit
}

30
util/limit_test.go Normal file
View File

@@ -0,0 +1,30 @@
package util
import (
"testing"
)
func TestLimiter_Add(t *testing.T) {
l := NewLimiter(10)
if err := l.Add(5); err != nil {
t.Fatal(err)
}
if err := l.Add(5); err != nil {
t.Fatal(err)
}
if err := l.Add(5); err != ErrLimitReached {
t.Fatalf("expected ErrLimitReached, got %#v", err)
}
}
func TestLimiter_AddSub(t *testing.T) {
l := NewLimiter(10)
l.Add(5)
if l.Value() != 5 {
t.Fatalf("expected value to be %d, got %d", 5, l.Value())
}
l.Sub(2)
if l.Value() != 3 {
t.Fatalf("expected value to be %d, got %d", 3, l.Value())
}
}

56
util/util_test.go Normal file
View File

@@ -0,0 +1,56 @@
package util
import (
"github.com/stretchr/testify/require"
"io/ioutil"
"path/filepath"
"testing"
"time"
)
func TestDurationToHuman_SevenDays(t *testing.T) {
d := 7 * 24 * time.Hour
require.Equal(t, "7d", DurationToHuman(d))
}
func TestDurationToHuman_MoreThanOneDay(t *testing.T) {
d := 49 * time.Hour
require.Equal(t, "2d1h", DurationToHuman(d))
}
func TestDurationToHuman_LessThanOneDay(t *testing.T) {
d := 17*time.Hour + 15*time.Minute
require.Equal(t, "17h15m", DurationToHuman(d))
}
func TestDurationToHuman_TenOfThings(t *testing.T) {
d := 10*time.Hour + 10*time.Minute + 10*time.Second
require.Equal(t, "10h10m10s", DurationToHuman(d))
}
func TestDurationToHuman_Zero(t *testing.T) {
require.Equal(t, "0", DurationToHuman(0))
}
func TestRandomString(t *testing.T) {
s1 := RandomString(10)
s2 := RandomString(10)
s3 := RandomString(12)
require.Equal(t, 10, len(s1))
require.Equal(t, 10, len(s2))
require.Equal(t, 12, len(s3))
require.NotEqual(t, s1, s2)
}
func TestFileExists(t *testing.T) {
filename := filepath.Join(t.TempDir(), "somefile.txt")
require.Nil(t, ioutil.WriteFile(filename, []byte{0x25, 0x86}, 0600))
require.True(t, FileExists(filename))
require.False(t, FileExists(filename+".doesnotexist"))
}
func TestInStringList(t *testing.T) {
s := []string{"one", "two"}
require.True(t, InStringList(s, "two"))
require.False(t, InStringList(s, "three"))
}