Compare commits
6 Commits
v1.19.0
...
update-mes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a327fe802e | ||
|
|
86baa80ab8 | ||
|
|
0a77c5296b | ||
|
|
b7871b80ab | ||
|
|
8939173a1e | ||
|
|
8848829dfa |
@@ -28,8 +28,9 @@ builds:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [7]
|
goarm: [7]
|
||||||
# No "upx", since it causes random core dumps, see
|
hooks:
|
||||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
post:
|
||||||
|
- upx "{{ .Path }}" # apt install upx
|
||||||
-
|
-
|
||||||
id: ntfy_arm64
|
id: ntfy_arm64
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
@@ -41,8 +42,9 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm64]
|
goarch: [arm64]
|
||||||
# No "upx", since it causes random core dumps, see
|
hooks:
|
||||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
post:
|
||||||
|
- upx "{{ .Path }}" # apt install upx
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
-
|
||||||
package_name: ntfy
|
package_name: ntfy
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ too.
|
|||||||
[Building](https://ntfy.sh/docs/develop/)
|
[Building](https://ntfy.sh/docs/develop/)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
|
I welcome any and all contributions. Just create a PR or an issue.
|
||||||
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
|
|
||||||
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
|
||||||
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/ntfy/">
|
|
||||||
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Contact me
|
## Contact me
|
||||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ statuspage.io (though these days most services also support webhooks and HTTP ca
|
|||||||
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
||||||
|
|
||||||
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
||||||
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)
|
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
|
||||||
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
||||||
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
||||||
accepted (which may obviously be a spam problem).
|
accepted (which may obviously be a spam problem).
|
||||||
@@ -369,42 +369,6 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
|||||||
<figcaption>DNS records for incoming mail</figcaption>
|
<figcaption>DNS records for incoming mail</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
You can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g.
|
|
||||||
`email.txt`
|
|
||||||
|
|
||||||
```
|
|
||||||
EHLO example.com
|
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Subject: Email for you
|
|
||||||
Content-Type: text/plain; charset="UTF-8"
|
|
||||||
|
|
||||||
Hello from 🇩🇪
|
|
||||||
.
|
|
||||||
```
|
|
||||||
|
|
||||||
And then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the
|
|
||||||
ntfy server. Read them carefully.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ cat email.txt | nc -N ntfy.sh 25
|
|
||||||
220 ntfy.sh ESMTP Service Ready
|
|
||||||
250-Hello example.com
|
|
||||||
...
|
|
||||||
250 2.0.0 Roger, accepting mail from <phil@example.com>
|
|
||||||
250 2.0.0 I'll make sure <ntfy-mytopic@ntfy.sh> gets this
|
|
||||||
```
|
|
||||||
|
|
||||||
As for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ dig MX ntfy.sh +short
|
|
||||||
10 mx1.ntfy.sh.
|
|
||||||
$ dig A mx1.ntfy.sh +short
|
|
||||||
3.139.215.220
|
|
||||||
```
|
|
||||||
|
|
||||||
## Behind a proxy (TLS, etc.)
|
## Behind a proxy (TLS, etc.)
|
||||||
!!! warning
|
!!! warning
|
||||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
|
|||||||
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
|
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
|
||||||
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
|
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
|
||||||
|
|
||||||
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
|
|
||||||
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
|
|
||||||
WebSocket in June.
|
|
||||||
|
|
||||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||||
> Active since 2022-02-27, behavior will change in **May 2022**
|
> Active since 2022-02-27, behavior will change in **May 2022**
|
||||||
|
|
||||||
|
|||||||
351
docs/examples.md
351
docs/examples.md
@@ -132,213 +132,186 @@ Some simple bash scripts to achieve this are kindly provided in [nickexyz's repo
|
|||||||
## Node-RED
|
## Node-RED
|
||||||
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||||
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Example: Send a message (click to expand)</summary>
|
<summary>Example: Send a message</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "c956e688cc74ad8e",
|
"id": "8f09d37dd5773f88",
|
||||||
"type": "http request",
|
"type": "http request",
|
||||||
"z": "fabdd7a3.4045a",
|
"z": "ff3ad4e1.d3415",
|
||||||
"name": "ntfy.sh",
|
"name": "ntfy",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"ret": "txt",
|
"ret": "txt",
|
||||||
"paytoqs": "ignore",
|
"paytoqs": "ignore",
|
||||||
"url": "https://ntfy.sh/mytopic",
|
"url": "https://example.com/topic",
|
||||||
"tls": "",
|
"tls": "",
|
||||||
"persist": false,
|
"persist": false,
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"authType": "",
|
"authType": "",
|
||||||
"senderr": false,
|
"senderr": false,
|
||||||
"credentials":
|
"credentials": {},
|
||||||
{
|
"x": 1410,
|
||||||
"user": "",
|
"y": 740,
|
||||||
"password": ""
|
"wires": [
|
||||||
},
|
[]
|
||||||
"x": 590,
|
]
|
||||||
"y": 3160,
|
},
|
||||||
"wires":
|
{
|
||||||
[
|
"id": "2603f296b25fe351",
|
||||||
[]
|
"type": "function",
|
||||||
]
|
"z": "ff3ad4e1.d3415",
|
||||||
},
|
"name": "data",
|
||||||
{
|
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
||||||
"id": "32ee1eade51fae50",
|
"outputs": 1,
|
||||||
"type": "function",
|
"noerr": 0,
|
||||||
"z": "fabdd7a3.4045a",
|
"initialize": "",
|
||||||
"name": "data",
|
"finalize": "",
|
||||||
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
"libs": [],
|
||||||
"outputs": 1,
|
"x": 1290,
|
||||||
"noerr": 0,
|
"y": 740,
|
||||||
"initialize": "",
|
"wires": [
|
||||||
"finalize": "",
|
[
|
||||||
"libs": [],
|
"8f09d37dd5773f88"
|
||||||
"x": 470,
|
]
|
||||||
"y": 3160,
|
]
|
||||||
"wires":
|
},
|
||||||
[
|
{
|
||||||
[
|
"id": "d2351ed0720a239f",
|
||||||
"c956e688cc74ad8e"
|
"type": "inject",
|
||||||
]
|
"z": "ff3ad4e1.d3415",
|
||||||
]
|
"name": "Manual start",
|
||||||
},
|
"props": [
|
||||||
{
|
{
|
||||||
"id": "b287e59cd2311815",
|
"p": "payload"
|
||||||
"type": "inject",
|
},
|
||||||
"z": "fabdd7a3.4045a",
|
{
|
||||||
"name": "Manual start",
|
"p": "topic",
|
||||||
"props":
|
"vt": "str"
|
||||||
[
|
}
|
||||||
{
|
],
|
||||||
"p": "payload"
|
"repeat": "",
|
||||||
},
|
"crontab": "",
|
||||||
{
|
"once": false,
|
||||||
"p": "topic",
|
"onceDelay": "20",
|
||||||
"vt": "str"
|
"topic": "",
|
||||||
}
|
"payload": "",
|
||||||
],
|
"payloadType": "date",
|
||||||
"repeat": "",
|
"x": 1150,
|
||||||
"crontab": "",
|
"y": 740,
|
||||||
"once": false,
|
"wires": [
|
||||||
"onceDelay": "20",
|
[
|
||||||
"topic": "",
|
"2603f296b25fe351"
|
||||||
"payload": "",
|
]
|
||||||
"payloadType": "date",
|
]
|
||||||
"x": 330,
|
}
|
||||||
"y": 3160,
|
|
||||||
"wires":
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"32ee1eade51fae50"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Example: Send a picture (click to expand)</summary>
|
<summary>Example: Send a picture</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "d135a13eadeb9d6d",
|
"id": "726d0d75d6c0f70e",
|
||||||
"type": "http request",
|
"type": "http request",
|
||||||
"z": "fabdd7a3.4045a",
|
"z": "ff3ad4e1.d3415",
|
||||||
"name": "Download image",
|
"name": "Download jpeg",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"ret": "bin",
|
"ret": "bin",
|
||||||
"paytoqs": "ignore",
|
"paytoqs": "ignore",
|
||||||
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
|
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
|
||||||
"tls": "",
|
"tls": "",
|
||||||
"persist": false,
|
"persist": false,
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"authType": "",
|
"authType": "",
|
||||||
"senderr": false,
|
"senderr": false,
|
||||||
"credentials":
|
"credentials": {},
|
||||||
{
|
"x": 1320,
|
||||||
"user": "",
|
"y": 780,
|
||||||
"password": ""
|
"wires": [
|
||||||
},
|
[
|
||||||
"x": 490,
|
"730dbbc9dbf1ed8a"
|
||||||
"y": 3320,
|
]
|
||||||
"wires":
|
]
|
||||||
[
|
},
|
||||||
[
|
{
|
||||||
"6e75bc41d2ec4a03"
|
"id": "730dbbc9dbf1ed8a",
|
||||||
]
|
"type": "function",
|
||||||
]
|
"z": "ff3ad4e1.d3415",
|
||||||
},
|
"name": "data",
|
||||||
{
|
"func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;",
|
||||||
"id": "6e75bc41d2ec4a03",
|
"outputs": 1,
|
||||||
"type": "function",
|
"noerr": 0,
|
||||||
"z": "fabdd7a3.4045a",
|
"initialize": "",
|
||||||
"name": "data",
|
"finalize": "",
|
||||||
"func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;",
|
"libs": [],
|
||||||
"outputs": 1,
|
"x": 1470,
|
||||||
"noerr": 0,
|
"y": 780,
|
||||||
"initialize": "",
|
"wires": [
|
||||||
"finalize": "",
|
[
|
||||||
"libs": [],
|
"592f424b37f76f5c"
|
||||||
"x": 650,
|
]
|
||||||
"y": 3320,
|
]
|
||||||
"wires":
|
},
|
||||||
[
|
{
|
||||||
[
|
"id": "592f424b37f76f5c",
|
||||||
"eb160615b6ceda98"
|
"type": "http request",
|
||||||
]
|
"z": "ff3ad4e1.d3415",
|
||||||
]
|
"name": "ntfy",
|
||||||
},
|
"method": "PUT",
|
||||||
{
|
"ret": "bin",
|
||||||
"id": "eb160615b6ceda98",
|
"paytoqs": "ignore",
|
||||||
"type": "http request",
|
"url": "https://example.com/topic",
|
||||||
"z": "fabdd7a3.4045a",
|
"tls": "",
|
||||||
"name": "ntfy.sh",
|
"persist": false,
|
||||||
"method": "PUT",
|
"proxy": "",
|
||||||
"ret": "bin",
|
"authType": "",
|
||||||
"paytoqs": "ignore",
|
"senderr": false,
|
||||||
"url": "https://ntfy.sh/mytopic",
|
"x": 1590,
|
||||||
"tls": "",
|
"y": 780,
|
||||||
"persist": false,
|
"wires": [
|
||||||
"proxy": "",
|
[]
|
||||||
"authType": "",
|
]
|
||||||
"senderr": false,
|
},
|
||||||
"credentials":
|
{
|
||||||
{
|
"id": "8aa06dda3c902f6a",
|
||||||
"user": "",
|
"type": "inject",
|
||||||
"password": ""
|
"z": "ff3ad4e1.d3415",
|
||||||
},
|
"name": "Manual start",
|
||||||
"x": 770,
|
"props": [
|
||||||
"y": 3320,
|
{
|
||||||
"wires":
|
"p": "payload"
|
||||||
[
|
},
|
||||||
[]
|
{
|
||||||
]
|
"p": "topic",
|
||||||
},
|
"vt": "str"
|
||||||
{
|
}
|
||||||
"id": "5b8dbf15c8a7a3a5",
|
],
|
||||||
"type": "inject",
|
"repeat": "",
|
||||||
"z": "fabdd7a3.4045a",
|
"crontab": "",
|
||||||
"name": "Manual start",
|
"once": false,
|
||||||
"props":
|
"onceDelay": "20",
|
||||||
[
|
"topic": "",
|
||||||
{
|
"payload": "",
|
||||||
"p": "payload"
|
"payloadType": "date",
|
||||||
},
|
"x": 1150,
|
||||||
{
|
"y": 780,
|
||||||
"p": "topic",
|
"wires": [
|
||||||
"vt": "str"
|
[
|
||||||
}
|
"726d0d75d6c0f70e"
|
||||||
],
|
]
|
||||||
"repeat": "",
|
]
|
||||||
"crontab": "",
|
}
|
||||||
"once": false,
|
|
||||||
"onceDelay": "20",
|
|
||||||
"topic": "",
|
|
||||||
"payload": "",
|
|
||||||
"payloadType": "date",
|
|
||||||
"x": 310,
|
|
||||||
"y": 3320,
|
|
||||||
"wires":
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"d135a13eadeb9d6d"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Gatus service health check
|
## Gatus service health check
|
||||||
|
|
||||||
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
||||||
|
|||||||
@@ -26,28 +26,28 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_1.19.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_1.18.1_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_1.19.0_linux_x86_64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.18.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.19.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_1.19.0_linux_armv7.tar.gz
|
tar zxvf ntfy_1.18.1_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_1.19.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.18.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.19.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_1.19.0_linux_arm64.tar.gz
|
tar zxvf ntfy_1.18.1_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_1.19.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.18.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.19.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -102,7 +102,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -110,7 +110,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -120,21 +120,21 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -194,3 +194,13 @@ COPY server.yml /etc/ntfy/server.yml
|
|||||||
ENTRYPOINT ["ntfy", "serve"]
|
ENTRYPOINT ["ntfy", "serve"]
|
||||||
```
|
```
|
||||||
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||||
|
|
||||||
|
## Go
|
||||||
|
To install via Go, simply run:
|
||||||
|
```bash
|
||||||
|
go install heckel.io/ntfy@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
Please [let me know](https://github.com/binwiederhier/ntfy/issues) if there are any issues with this installation
|
||||||
|
method. The SQLite bindings require CGO and it works for me, but I have the feeling it may not work for everyone.
|
||||||
|
|||||||
@@ -661,8 +661,7 @@ the example.
|
|||||||
To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're
|
To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're
|
||||||
POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
|
POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
|
||||||
|
|
||||||
Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter
|
Here's an example using all supported parameters. The `topic` parameter is the only required one:
|
||||||
is the only required one:
|
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
@@ -799,8 +798,7 @@ all the supported fields:
|
|||||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
||||||
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
||||||
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
|
||||||
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
|
||||||
|
|
||||||
## Click action
|
## Click action
|
||||||
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181))
|
* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181))
|
||||||
* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142))
|
* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142))
|
||||||
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
|
|
||||||
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
|
|
||||||
|
|
||||||
**Bugs:**
|
**Bugs:**
|
||||||
|
|
||||||
@@ -19,41 +17,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
|
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
|
||||||
* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
|
||||||
**Translations:**
|
|
||||||
|
|
||||||
* English language improvements (thanks to [@comradekingu](https://github.com/comradekingu))
|
|
||||||
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
|
||||||
* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy))
|
|
||||||
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony))
|
|
||||||
* French (*incomplete*, thanks to [@Kusoneko](https://kusoneko.moe/))
|
|
||||||
* German (thanks to [@cmeis](https://github.com/cmeis))
|
|
||||||
* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/))
|
|
||||||
* Norwegian (*incomplete*, thanks to [@comradekingu](https://github.com/comradekingu))
|
|
||||||
* Portuguese/Brazil (thanks to [@LW](https://hosted.weblate.org/user/LW/))
|
|
||||||
* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
|
||||||
* Turkish (thanks to [@ersen](https://ersen.moe/))
|
|
||||||
|
|
||||||
**Thanks:**
|
**Thanks:**
|
||||||
|
|
||||||
* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94),
|
* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94),
|
||||||
and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release
|
and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release
|
||||||
|
|
||||||
-->
|
## ntfy server v1.19.0 (UNRELEASED)
|
||||||
|
|
||||||
## ntfy server v1.19.0
|
|
||||||
Released Mar 30, 2022
|
|
||||||
|
|
||||||
**Bugs:**
|
**Bugs:**
|
||||||
|
|
||||||
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
|
|
||||||
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
|
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
|
||||||
* Add "Access-Control-Allow-Origin: *" for attachments (no ticket, thanks to @FrameXX)
|
|
||||||
* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186))
|
|
||||||
* Added missing params `delay` and `email` to publish as JSON body (no ticket)
|
|
||||||
|
|
||||||
**Documentation:**
|
-->
|
||||||
|
|
||||||
* Improved [e-mail publishing](config.md#e-mail-publishing) documentation
|
|
||||||
|
|
||||||
## ntfy server v1.18.1
|
## ntfy server v1.18.1
|
||||||
Released Mar 21, 2022
|
Released Mar 21, 2022
|
||||||
|
|||||||
BIN
docs/static/img/nodered-message.png
vendored
BIN
docs/static/img/nodered-message.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB |
BIN
docs/static/img/nodered-picture.png
vendored
BIN
docs/static/img/nodered-picture.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 9.9 KiB |
@@ -40,14 +40,16 @@ var (
|
|||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "not found", ""}
|
||||||
|
errHTTPNotFoundMessageID = &errHTTP{40402, http.StatusNotFound, "not found: unable to find message with this ID", "https://ntfy.sh/docs/publish/#updating-messages"} // FIXME LINK
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth limit reached, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsUpdatingTooQuickly = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many consecutive message updates", "https://ntfy.sh/docs/publish/#updating-messages"} // FIXME LINK
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
mid TEXT NOT NULL,
|
mid TEXT NOT NULL,
|
||||||
time INT NOT NULL,
|
time INT NOT NULL,
|
||||||
|
updated INT NOT NULL,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -43,41 +44,47 @@ const (
|
|||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
INSERT INTO messages (mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
updateMessageQuery = `UPDATE messages SET updated = ?, message = ?, title = ?, priority = ?, tags = ?, click = ? WHERE topic = ? AND mid = ?`
|
||||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id >= ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
|
selectMessageByIDQuery = `
|
||||||
|
SELECT mid, time, updated, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND mid = ?
|
||||||
|
`
|
||||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||||
@@ -88,7 +95,7 @@ const (
|
|||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 5
|
currentSchemaVersion = 6
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -166,6 +173,11 @@ const (
|
|||||||
ALTER TABLE messages_new RENAME TO messages;
|
ALTER TABLE messages_new RENAME TO messages;
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 5 -> 6
|
||||||
|
migrate5To6AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN updated INT NOT NULL DEFAULT (0);
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
type messageCache struct {
|
type messageCache struct {
|
||||||
@@ -232,6 +244,7 @@ func (c *messageCache) AddMessage(m *message) error {
|
|||||||
insertMessageQuery,
|
insertMessageQuery,
|
||||||
m.ID,
|
m.ID,
|
||||||
m.Time,
|
m.Time,
|
||||||
|
m.Updated,
|
||||||
m.Topic,
|
m.Topic,
|
||||||
m.Message,
|
m.Message,
|
||||||
m.Title,
|
m.Title,
|
||||||
@@ -250,6 +263,28 @@ func (c *messageCache) AddMessage(m *message) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) UpdateMessage(m *message) error {
|
||||||
|
if m.Event != messageEvent {
|
||||||
|
return errUnexpectedMessageType
|
||||||
|
}
|
||||||
|
if c.nop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tags := strings.Join(m.Tags, ",")
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
updateMessageQuery,
|
||||||
|
m.Updated,
|
||||||
|
m.Message,
|
||||||
|
m.Title,
|
||||||
|
m.Priority,
|
||||||
|
tags,
|
||||||
|
m.Click,
|
||||||
|
m.Topic,
|
||||||
|
m.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
return make([]*message, 0), nil
|
return make([]*message, 0), nil
|
||||||
@@ -296,7 +331,15 @@ func (c *messageCache) messagesSinceID(topic string, since sinceMarker, schedule
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return readMessages(rows)
|
messages, err := readMessages(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if len(messages) == 0 {
|
||||||
|
return messages, nil
|
||||||
|
} else if since.IsTime() && messages[0].Updated > since.Time().Unix() {
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
return messages[1:], nil // Do not include row with ID itself
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MessagesDue() ([]*message, error) {
|
func (c *messageCache) MessagesDue() ([]*message, error) {
|
||||||
@@ -393,16 +436,31 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) Message(topic, id string) (*message, error) {
|
||||||
|
rows, err := c.db.Query(selectMessageByIDQuery, topic, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages, err := readMessages(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if len(messages) == 0 {
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
return messages[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func readMessages(rows *sql.Rows) ([]*message, error) {
|
func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
messages := make([]*message, 0)
|
messages := make([]*message, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var timestamp, attachmentSize, attachmentExpires int64
|
var timestamp, updated, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&id,
|
&id,
|
||||||
×tamp,
|
×tamp,
|
||||||
|
&updated,
|
||||||
&topic,
|
&topic,
|
||||||
&msg,
|
&msg,
|
||||||
&title,
|
&title,
|
||||||
@@ -438,6 +496,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
messages = append(messages, &message{
|
messages = append(messages, &message{
|
||||||
ID: id,
|
ID: id,
|
||||||
Time: timestamp,
|
Time: timestamp,
|
||||||
|
Updated: updated,
|
||||||
Event: messageEvent,
|
Event: messageEvent,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
@@ -490,6 +549,8 @@ func setupCacheDB(db *sql.DB) error {
|
|||||||
return migrateFrom3(db)
|
return migrateFrom3(db)
|
||||||
} else if schemaVersion == 4 {
|
} else if schemaVersion == 4 {
|
||||||
return migrateFrom4(db)
|
return migrateFrom4(db)
|
||||||
|
} else if schemaVersion == 5 {
|
||||||
|
return migrateFrom5(db)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||||
}
|
}
|
||||||
@@ -562,5 +623,16 @@ func migrateFrom4(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return migrateFrom5(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom5(db *sql.DB) error {
|
||||||
|
log.Print("Migrating cache database schema: from 5 to 6")
|
||||||
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil // Update this when a new version is added
|
return nil // Update this when a new version is added
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
updateTopicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[A-Za-z0-9]{12}$`) // ID length must match messageIDLength & util.randomStringCharset
|
||||||
|
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||||
@@ -279,7 +280,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.handleOptions(w, r)
|
return s.handleOptions(w, r)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
||||||
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
|
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updateTopicPathRegex.MatchString(r.URL.Path)) {
|
||||||
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
||||||
@@ -380,7 +381,6 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
|
||||||
f, err := os.Open(file)
|
f, err := os.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -391,7 +391,26 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
t, err := s.topicFromPath(r.URL.Path)
|
t, messageID, err := s.topicAndMessageIDFromPath(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var m *message
|
||||||
|
update := messageID != ""
|
||||||
|
if update {
|
||||||
|
m, err = s.messageCache.Message(t.ID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return errHTTPNotFoundMessageID
|
||||||
|
}
|
||||||
|
newUpdated := time.Now().Unix()
|
||||||
|
if newUpdated <= m.Updated {
|
||||||
|
return errHTTPTooManyRequestsUpdatingTooQuickly
|
||||||
|
}
|
||||||
|
m.Updated = newUpdated
|
||||||
|
} else {
|
||||||
|
m = newDefaultMessage(t.ID, "")
|
||||||
|
}
|
||||||
|
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -399,11 +418,6 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, "")
|
|
||||||
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -431,8 +445,14 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if cache {
|
if cache {
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if update {
|
||||||
return err
|
if err := s.messageCache.UpdateMessage(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -448,9 +468,19 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
|
if !cache && m.Updated != 0 {
|
||||||
|
return false, false, "", false, errors.New("message updates must be cached")
|
||||||
|
}
|
||||||
|
// TODO more restrictions
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
title := readParam(r, "x-title", "title", "t")
|
||||||
m.Click = readParam(r, "x-click", "click")
|
if title != "" {
|
||||||
|
m.Title = title
|
||||||
|
}
|
||||||
|
click := readParam(r, "x-click", "click")
|
||||||
|
if click != "" {
|
||||||
|
m.Click = click
|
||||||
|
}
|
||||||
filename := readParam(r, "x-filename", "filename", "file", "f")
|
filename := readParam(r, "x-filename", "filename", "file", "f")
|
||||||
attach := readParam(r, "x-attach", "attach", "a")
|
attach := readParam(r, "x-attach", "attach", "a")
|
||||||
if attach != "" || filename != "" {
|
if attach != "" || filename != "" {
|
||||||
@@ -490,9 +520,11 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
|||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
m.Message = messageStr
|
m.Message = messageStr
|
||||||
}
|
}
|
||||||
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
priority, err := util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
||||||
|
} else if priority > 0 {
|
||||||
|
m.Priority = priority
|
||||||
}
|
}
|
||||||
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
if tagsStr != "" {
|
if tagsStr != "" {
|
||||||
@@ -871,6 +903,13 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
|||||||
return sinceNoMessages, nil
|
return sinceNoMessages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ID/timestamp
|
||||||
|
parts := strings.Split(since, "/")
|
||||||
|
if len(parts) == 2 && validMessageID(parts[0]) && validUnixTimestamp(parts[1]) {
|
||||||
|
t, _ := toUnixTimestamp(parts[1])
|
||||||
|
return newSince(parts[0], t), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ID, timestamp, duration
|
// ID, timestamp, duration
|
||||||
if validMessageID(since) {
|
if validMessageID(since) {
|
||||||
return newSinceID(since), nil
|
return newSinceID(since), nil
|
||||||
@@ -889,16 +928,20 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) topicFromPath(path string) (*topic, error) {
|
func (s *Server) topicAndMessageIDFromPath(path string) (*topic, string, error) {
|
||||||
parts := strings.Split(path, "/")
|
parts := strings.Split(path, "/")
|
||||||
if len(parts) < 2 {
|
if len(parts) != 2 && len(parts) != 3 {
|
||||||
return nil, errHTTPBadRequestTopicInvalid
|
return nil, "", errHTTPBadRequestTopicInvalid
|
||||||
}
|
}
|
||||||
topics, err := s.topicsFromIDs(parts[1])
|
topics, err := s.topicsFromIDs(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return topics[0], nil
|
messageID := ""
|
||||||
|
if len(parts) == 3 && len(parts[2]) == messageIDLength {
|
||||||
|
messageID = parts[2]
|
||||||
|
}
|
||||||
|
return topics[0], messageID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
||||||
@@ -1135,12 +1178,6 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
|||||||
if m.Click != "" {
|
if m.Click != "" {
|
||||||
r.Header.Set("X-Click", m.Click)
|
r.Header.Set("X-Click", m.Click)
|
||||||
}
|
}
|
||||||
if m.Email != "" {
|
|
||||||
r.Header.Set("X-Email", m.Email)
|
|
||||||
}
|
|
||||||
if m.Delay != "" {
|
|
||||||
r.Header.Set("X-Delay", m.Delay)
|
|
||||||
}
|
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
|||||||
data = map[string]string{
|
data = map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"updated": fmt.Sprintf("%d", m.Updated),
|
||||||
"event": m.Event,
|
"event": m.Event,
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
"priority": fmt.Sprintf("%d", m.Priority),
|
"priority": fmt.Sprintf("%d", m.Priority),
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
require.Equal(t, map[string]string{
|
require.Equal(t, map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"updated": "0",
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
"priority": "4",
|
"priority": "4",
|
||||||
|
|||||||
@@ -390,6 +390,69 @@ func TestServer_PublishAndPollSince(t *testing.T) {
|
|||||||
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishUpdateAndPollSince(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Initial PUT
|
||||||
|
response := request(t, s, "PUT", "/mytopic?t=atitle&tags=tag1,tag2&prio=high&click=https://google.com&attach=https://heckel.io", "test 1", nil)
|
||||||
|
message1 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, int64(0), message1.Updated)
|
||||||
|
require.Equal(t, "test 1", message1.Message)
|
||||||
|
require.Equal(t, "atitle", message1.Title)
|
||||||
|
require.Equal(t, 4, message1.Priority)
|
||||||
|
require.Equal(t, []string{"tag1", "tag2"}, message1.Tags)
|
||||||
|
require.Equal(t, "https://google.com", message1.Click)
|
||||||
|
require.Equal(t, "https://heckel.io", message1.Attachment.URL)
|
||||||
|
|
||||||
|
// Update
|
||||||
|
response = request(t, s, "PUT", "/mytopic/"+message1.ID+"?prio=low", "test 2", nil)
|
||||||
|
message2 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, message1.ID, message2.ID)
|
||||||
|
require.True(t, message2.Updated > message1.Updated)
|
||||||
|
require.Equal(t, "test 2", message2.Message) // Updated
|
||||||
|
require.Equal(t, "atitle", message2.Title)
|
||||||
|
require.Equal(t, 2, message2.Priority) // Updated
|
||||||
|
require.Equal(t, []string{"tag1", "tag2"}, message2.Tags)
|
||||||
|
require.Equal(t, "https://google.com", message2.Click)
|
||||||
|
require.Equal(t, "https://heckel.io", message2.Attachment.URL)
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Another update
|
||||||
|
response = request(t, s, "PUT", "/mytopic/"+message1.ID+"?title=new+title", "test 3", nil)
|
||||||
|
message3 := toMessage(t, response.Body.String())
|
||||||
|
require.True(t, message3.Updated > message2.Updated)
|
||||||
|
require.Equal(t, "test 3", message3.Message) // Updated
|
||||||
|
require.Equal(t, "new title", message3.Title) // Updated
|
||||||
|
|
||||||
|
// Get all messages: Should be only one that was updated
|
||||||
|
since := "all"
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?since="+since+"&poll=1", "", nil)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, message1.ID, messages[0].ID)
|
||||||
|
require.Equal(t, "test 3", messages[0].Message)
|
||||||
|
|
||||||
|
// Get all messages since "message ID": Should be zero, since we know this message
|
||||||
|
since = message1.ID
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?since="+since+"&poll=1", "", nil)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 0, len(messages))
|
||||||
|
|
||||||
|
// Get all messages since "message ID" but with an older timestamp: Should be the latest updated message
|
||||||
|
since = fmt.Sprintf("%s/%d", message1.ID, message2.Updated) // We're missing an update
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?since="+since+"&poll=1", "", nil)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "test 3", messages[0].Message)
|
||||||
|
|
||||||
|
// Get all messages since "message ID" with the current timestamp: No messages expected
|
||||||
|
since = fmt.Sprintf("%s/%d", message3.ID, message3.Updated) // We are up-to-date
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?since="+since+"&poll=1", "", nil)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 0, len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishViaGET(t *testing.T) {
|
func TestServer_PublishViaGET(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -714,12 +777,6 @@ func (t *testMailer) Send(from, to string, m *message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testMailer) Count() int {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
return t.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
for i := 0; i < 60; i++ {
|
for i := 0; i < 60; i++ {
|
||||||
@@ -879,8 +936,7 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
|
|||||||
func TestServer_PublishAsJSON(t *testing.T) {
|
func TestServer_PublishAsJSON(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
||||||
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
|
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4}`
|
||||||
`"delay":"30min"}`
|
|
||||||
response := request(t, s, "PUT", "/", body, nil)
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
@@ -893,22 +949,6 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
|||||||
require.Equal(t, "google.pdf", m.Attachment.Name)
|
require.Equal(t, "google.pdf", m.Attachment.Name)
|
||||||
require.Equal(t, "http://ntfy.sh", m.Click)
|
require.Equal(t, "http://ntfy.sh", m.Click)
|
||||||
require.Equal(t, 4, m.Priority)
|
require.Equal(t, 4, m.Priority)
|
||||||
require.True(t, m.Time > time.Now().Unix()+29*60)
|
|
||||||
require.True(t, m.Time < time.Now().Unix()+31*60)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
|
||||||
mailer := &testMailer{}
|
|
||||||
s := newTestServer(t, newTestConfig(t))
|
|
||||||
s.mailer = mailer
|
|
||||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
|
||||||
response := request(t, s, "PUT", "/", body, nil)
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
|
|
||||||
m := toMessage(t, response.Body.String())
|
|
||||||
require.Equal(t, "mytopic", m.Topic)
|
|
||||||
require.Equal(t, "A message", m.Message)
|
|
||||||
require.Equal(t, 1, mailer.Count())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
|
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,9 +22,11 @@ const (
|
|||||||
|
|
||||||
// message represents a message published to a topic
|
// message represents a message published to a topic
|
||||||
type message struct {
|
type message struct {
|
||||||
ID string `json:"id"` // Random message ID
|
ID string `json:"id"` // Random message ID
|
||||||
Time int64 `json:"time"` // Unix time in seconds
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
Event string `json:"event"` // One of the above
|
Updated int64 `json:"updated,omitempty"` // Set if updated, unix time in seconds
|
||||||
|
Deleted int64 `json:"deleted,omitempty"` // Set if deleted, unix time in seconds
|
||||||
|
Event string `json:"event"` // One of the above
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Priority int `json:"priority,omitempty"`
|
Priority int `json:"priority,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
@@ -30,7 +34,7 @@ type message struct {
|
|||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
type attachment struct {
|
type attachment struct {
|
||||||
@@ -52,8 +56,6 @@ type publishMessage struct {
|
|||||||
Click string `json:"click"`
|
Click string `json:"click"`
|
||||||
Attach string `json:"attach"`
|
Attach string `json:"attach"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Email string `json:"email"`
|
|
||||||
Delay string `json:"delay"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// messageEncoder is a function that knows how to encode a message
|
// messageEncoder is a function that knows how to encode a message
|
||||||
@@ -92,11 +94,31 @@ func validMessageID(s string) bool {
|
|||||||
return util.ValidRandomString(s, messageIDLength)
|
return util.ValidRandomString(s, messageIDLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validUnixTimestamp(s string) bool {
|
||||||
|
_, err := toUnixTimestamp(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUnixTimestamp(s string) (int64, error) {
|
||||||
|
u, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if u < 1000000000 || u > 3000000000 { // I know. It's practical. So relax ...
|
||||||
|
return 0, errors.New("invalid unix date")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
type sinceMarker struct {
|
type sinceMarker struct {
|
||||||
time time.Time
|
time time.Time
|
||||||
id string
|
id string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newSince(id string, timestamp int64) sinceMarker {
|
||||||
|
return sinceMarker{time.Unix(timestamp, 0), id}
|
||||||
|
}
|
||||||
|
|
||||||
func newSinceTime(timestamp int64) sinceMarker {
|
func newSinceTime(timestamp int64) sinceMarker {
|
||||||
return sinceMarker{time.Unix(timestamp, 0), ""}
|
return sinceMarker{time.Unix(timestamp, 0), ""}
|
||||||
}
|
}
|
||||||
@@ -117,6 +139,10 @@ func (t sinceMarker) IsID() bool {
|
|||||||
return t.id != ""
|
return t.id != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) IsTime() bool {
|
||||||
|
return t.time.Unix() > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (t sinceMarker) Time() time.Time {
|
func (t sinceMarker) Time() time.Time {
|
||||||
return t.time
|
return t.time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // Update updateTopicPathRegex if changed
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a, a:visited {
|
a, a:visited {
|
||||||
color: #338574;
|
color: #3a9784;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
@@ -114,7 +114,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.anchor .anchorLink:hover {
|
.anchor .anchorLink:hover {
|
||||||
color: #338574;
|
color: #3a9784;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ figcaption {
|
|||||||
/* Header */
|
/* Header */
|
||||||
|
|
||||||
#header {
|
#header {
|
||||||
background: #338574;
|
background: #3a9784;
|
||||||
height: 130px;
|
height: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,6 @@ class Poller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const poller = new Poller();
|
const poller = new Poller();
|
||||||
|
poller.startWorker();
|
||||||
|
|
||||||
export default poller;
|
export default poller;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
|
|
||||||
const delayMillis = 25000; // 25 seconds
|
const delayMillis = 15000; // 15 seconds
|
||||||
const intervalMillis = 1800000; // 30 minutes
|
const intervalMillis = 1800000; // 30 minutes
|
||||||
|
|
||||||
class Pruner {
|
class Pruner {
|
||||||
@@ -35,4 +35,6 @@ class Pruner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pruner = new Pruner();
|
const pruner = new Pruner();
|
||||||
|
pruner.startWorker();
|
||||||
|
|
||||||
export default pruner;
|
export default pruner;
|
||||||
|
|||||||
@@ -65,13 +65,17 @@ class SubscriptionManager {
|
|||||||
|
|
||||||
/** Adds notification, or returns false if it already exists */
|
/** Adds notification, or returns false if it already exists */
|
||||||
async addNotification(subscriptionId, notification) {
|
async addNotification(subscriptionId, notification) {
|
||||||
const exists = await db.notifications.get(notification.id);
|
const existingNotification = await db.notifications.get(notification.id);
|
||||||
if (exists) {
|
if (existingNotification) {
|
||||||
return false;
|
const upToDate = (existingNotification?.updated ?? 0) >= (notification.updated ?? 0);
|
||||||
|
if (upToDate) {
|
||||||
|
console.error(`[SubscriptionManager] up to date`, existingNotification, notification);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
await db.notifications.put({ ...notification, subscriptionId });
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
last: notification.id
|
last: notification.id
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from
|
|||||||
import {expandUrl} from "../app/utils";
|
import {expandUrl} from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import {useAutoSubscribe, useConnectionListeners, useBackgroundProcesses} from "./hooks";
|
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
|
||||||
|
|
||||||
// TODO add drag and drop
|
// TODO add drag and drop
|
||||||
// TODO races when two tabs are open
|
// TODO races when two tabs are open
|
||||||
@@ -67,7 +67,7 @@ const Layout = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useConnectionListeners(subscriptions, users);
|
useConnectionListeners(subscriptions, users);
|
||||||
useBackgroundProcesses();
|
useLocalStorageMigration();
|
||||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import notifier from "../app/Notifier";
|
|||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import connectionManager from "../app/ConnectionManager";
|
import connectionManager from "../app/ConnectionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
import pruner from "../app/Pruner";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||||
@@ -68,13 +67,29 @@ export const useAutoSubscribe = (subscriptions, selected) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
|
* Migrate the 'topics' item in localStorage to the subscriptionManager. This is only done once to migrate away
|
||||||
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
|
* from the old web UI.
|
||||||
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
|
|
||||||
*/
|
*/
|
||||||
export const useBackgroundProcesses = () => {
|
export const useLocalStorageMigration = () => {
|
||||||
|
const [hasRun, setHasRun] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poller.startWorker();
|
if (hasRun) {
|
||||||
pruner.startWorker();
|
return;
|
||||||
|
}
|
||||||
|
const topicsStr = localStorage.getItem("topics");
|
||||||
|
if (topicsStr) {
|
||||||
|
const topics = JSON.parse(topicsStr).filter(topic => topic !== "");
|
||||||
|
if (topics.length > 0) {
|
||||||
|
(async () => {
|
||||||
|
for (const topic of topics) {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const subscription = await subscriptionManager.add(baseUrl, topic);
|
||||||
|
poller.pollInBackground(subscription); // Dangle!
|
||||||
|
}
|
||||||
|
localStorage.removeItem("topics");
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHasRun(true);
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user