Compare commits

..

6 Commits

Author SHA1 Message Date
Philipp Heckel
a327fe802e Make web app work for updated notifications 2022-03-25 11:01:07 -04:00
Philipp Heckel
86baa80ab8 Merge branch 'main' into update-messages 2022-03-24 19:30:27 -04:00
Philipp Heckel
0a77c5296b Add since=$ID/$timestamp parsing logic 2022-03-24 17:05:07 -04:00
Philipp Heckel
b7871b80ab Merge branch 'main' into update-messages 2022-03-24 13:27:04 -04:00
Philipp Heckel
8939173a1e Continued work 2022-03-23 21:51:38 -04:00
Philipp Heckel
8848829dfa WIP: Update messages 2022-03-23 16:39:22 -04:00
24 changed files with 494 additions and 381 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
![Node red message flow](static/img/nodered-message.png)
<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>
![Node red picture flow](static/img/nodered-picture.png)
## 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>

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import (
) )
const ( const (
randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // Update updateTopicPathRegex if changed
) )
var ( var (

View File

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

View File

@@ -56,4 +56,6 @@ class Poller {
} }
const poller = new Poller(); const poller = new Poller();
poller.startWorker();
export default poller; export default poller;

View File

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

View File

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

View File

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

View File

@@ -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);
}, []); }, []);
} }