Compare commits

...

79 Commits

Author SHA1 Message Date
Philipp Heckel
749e334396 Bump version 2022-04-06 20:33:19 -04:00
Philipp Heckel
78a681f277 Fix UTF-8 issues in publish message dialog 2022-04-06 20:04:27 -04:00
Philipp Heckel
3f96fad7ce Remove now unused splitTopicUrl function 2022-04-06 13:27:32 -04:00
Philipp Heckel
83bb9951b0 Split baseUrl and topic 2022-04-05 23:33:07 -04:00
Philipp Heckel
4a5f34801a Do not hide notification behind message bar 2022-04-05 22:57:57 -04:00
Philipp Heckel
2cd7839da3 Docblock 2022-04-05 19:55:43 -04:00
Philipp Heckel
35ddcb27f0 Good enough emoji picker 2022-04-05 19:40:34 -04:00
Philipp Heckel
328aca48ab Filter emojis that don't render in Chrome on Desktop 2022-04-04 20:44:40 -04:00
Philipp Heckel
4eba641ec3 Emoji picker 2022-04-04 19:56:21 -04:00
Philipp Heckel
f2d4af04e3 Emoji picker 2022-04-04 10:04:01 -04:00
Philipp Heckel
d44ee2bbf6 Rename Icon->AttachmentIcon 2022-04-04 08:40:54 -04:00
Philipp Heckel
6f07944442 Publish message button 2022-04-03 22:58:44 -04:00
Philipp Heckel
7716b1e81e Push drop zone down to dialog 2022-04-03 22:42:56 -04:00
Philipp Heckel
8914809775 Remove showOpen 2022-04-03 22:28:41 -04:00
Philipp Heckel
dcb5531038 Merge branch 'main' into custom-messages 2022-04-03 22:17:45 -04:00
Philipp Heckel
0c666f96b1 Added Apprise to release notes 2022-04-03 22:17:29 -04:00
Philipp Heckel
d9c3c20350 Publish message button 2022-04-03 22:11:26 -04:00
Philipp Heckel
73349cd423 Add test 2022-04-03 20:19:43 -04:00
Philipp Heckel
b3667a916b Merge branch 'main' into custom-messages 2022-04-03 19:51:46 -04:00
Philipp Heckel
6791c7395b Almost there 2022-04-03 19:51:32 -04:00
Philipp Heckel
aba7e86cbc Attachment behavior fix for Firefox 2022-04-03 12:39:52 -04:00
Philipp Heckel
12f973f61b Changelog 2022-04-02 17:16:46 -04:00
Philipp Heckel
f98743dd9b Continued work on send dialog and drag and drop 2022-04-02 17:06:26 -04:00
Philipp Heckel
2c8b258ae7 Publish another checkbox 2022-04-01 11:34:53 -04:00
Philipp Heckel
00520a7a38 Merge branch 'main' into custom-messages 2022-04-01 08:54:29 -04:00
Philipp Heckel
611894fd05 Release notes, add EXPOSE 80 to Dockerfile 2022-04-01 08:49:15 -04:00
Philipp Heckel
aabae53e5d File upload 2022-04-01 08:41:45 -04:00
Philipp C. Heckel
85cf7bb687 Merge pull request #194 from RasHas/patch-1
Update install docs
2022-03-31 18:17:07 -04:00
Philipp C. Heckel
44b9358c60 Update install.md 2022-03-31 18:16:56 -04:00
RasHas
ee6188d100 Update install docs
Add docker-compose example
2022-03-31 23:11:39 +03:00
Philipp Heckel
2bdae49425 Make Attach URL prettier 2022-03-31 12:03:36 -04:00
Philipp Heckel
9814a9f792 Merge branch 'main' into custom-messages 2022-03-31 10:06:55 -04:00
Philipp Heckel
5aedfd3898 Changelog 2022-03-30 20:17:11 -04:00
Philipp Heckel
59ec2de8bd Fix race in test 2022-03-30 14:37:42 -04:00
Philipp Heckel
d154d3936d Bump version, release notes 2022-03-30 14:26:31 -04:00
Philipp Heckel
5125aac91c Remove upx for arm64/armv7, more translation credits 2022-03-30 14:23:57 -04:00
Philipp Heckel
7ff34364a3 Editable attachment filename 2022-03-30 14:11:18 -04:00
Philipp Heckel
3d0d70dc17 Merge branch 'main' into custom-messages 2022-03-30 10:01:40 -04:00
Philipp Heckel
62512b7a1a Change deprecation warning 2022-03-30 10:01:16 -04:00
Philipp Heckel
c5a1344e8a WIP: Make attachment filename editabe 2022-03-30 09:57:22 -04:00
Philipp Heckel
402b05a27b Merge branch 'main' into custom-messages 2022-03-29 19:37:06 -04:00
Philipp Heckel
b67d9fc85d Added missing 'delay' and 'email' params to publish as json 2022-03-29 15:40:26 -04:00
Philipp Heckel
3e121f5d3c Continued work on the send dialog 2022-03-29 15:22:26 -04:00
Philipp Heckel
b6426f0417 Merge branch 'main' into custom-messages 2022-03-29 11:54:50 -04:00
Philipp Heckel
59b341dfb8 Fix color of home page 2022-03-29 11:47:56 -04:00
Philipp Heckel
e2834a7c4d Changelog 2022-03-29 10:44:12 -04:00
Philipp Heckel
e0b3068a5e Merge branch 'main' into custom-messages 2022-03-28 23:10:54 -04:00
Philipp Heckel
2280031a80 Release notes, translations 2022-03-28 23:10:44 -04:00
Philipp Heckel
8f2851e20a Release notes, translations 2022-03-28 23:10:05 -04:00
Philipp Heckel
2eeb7d63a0 SendDialog, cont'd 2022-03-28 22:54:27 -04:00
Philipp Heckel
b20df55b88 Merge branch 'main' into custom-messages 2022-03-28 14:14:20 -04:00
Philipp Heckel
de1b97bbce Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-03-28 14:10:24 -04:00
Philipp Heckel
3b4a4108e5 Release log 2022-03-28 14:10:14 -04:00
Philipp C. Heckel
dc1c0ddd4e Update README.md 2022-03-28 11:07:05 -04:00
Philipp Heckel
182e21a9c3 Fix pruning bug in web app (closes #186), release notes, remove local storage migration 2022-03-27 09:20:25 -04:00
Philipp Heckel
187c19f3b2 Continued work on publishing from the web app 2022-03-27 09:10:47 -04:00
Philipp Heckel
d5eff0cd34 Merge branch 'main' into custom-messages 2022-03-26 14:17:28 -04:00
Philipp Heckel
d4fe2052c7 Release notes 2022-03-26 13:33:16 -04:00
Philipp Heckel
2e92be0f23 Remove other fields 2022-03-26 09:32:13 -04:00
Philipp Heckel
94b0e6f690 Merge branch 'main' into custom-messages 2022-03-25 21:43:45 -04:00
Philipp Heckel
202051bbbf Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-03-25 21:35:56 -04:00
Philipp Heckel
a693975526 Email docs and release notes 2022-03-25 21:35:40 -04:00
Philipp C. Heckel
4cd4e890fe Update README.md 2022-03-25 18:56:22 -04:00
Philipp C. Heckel
5dc8031ec9 Update README.md 2022-03-25 18:54:40 -04:00
Philipp Heckel
03ad5dcff6 Add Allow-Origin: *, because YOLO 2022-03-25 17:17:24 -04:00
Philipp Heckel
5f508e1839 Tiny docs changes 2022-03-25 13:51:04 -04:00
Philipp C. Heckel
c5642799df Merge pull request #178 from nickexyz/docs-noderedupdate
Add Node-RED pictures and change ntfy URL to ntfy.sh
2022-03-25 13:32:05 -04:00
Philipp Heckel
5a99fe8ba2 Try it a better way 2022-03-24 19:27:07 -04:00
Philipp Heckel
ee0f448d86 Another try 2022-03-24 19:25:41 -04:00
Philipp Heckel
a222f64ee4 Hopefully fix the build 2022-03-24 19:23:30 -04:00
Philipp Heckel
140daec0d3 Fix date formatting issue in example 2022-03-24 13:17:04 -04:00
Philipp Heckel
b409c89d3b Do not allow comma in topic name in publish via GET endpoint (no ticket) 2022-03-23 14:29:55 -04:00
Philipp Heckel
806893962c Changelog 2022-03-23 11:02:12 -04:00
Philipp Heckel
14d3c5e93e Changelog 2022-03-22 20:31:43 -04:00
Philipp Heckel
37e14b13a4 Update deps 2022-03-22 19:54:20 -04:00
Philipp Heckel
d7fa51be2c Changelog 2022-03-22 15:26:15 -04:00
Philipp Heckel
cc90a1af15 WIP: custom messages 2022-03-20 13:52:07 -04:00
nickexyz
21fc1245eb Update examples.md 2022-03-19 00:20:56 +00:00
Niclas Andersson
2511ba7627 Add Node-RED pictures and change ntfy URL to ntfy.sh 2022-03-19 01:16:02 +01:00
44 changed files with 3099 additions and 2019 deletions

View File

@@ -28,9 +28,8 @@ builds:
goos: [linux]
goarch: [arm]
goarm: [7]
hooks:
post:
- upx "{{ .Path }}" # apt install upx
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
-
id: ntfy_arm64
binary: ntfy
@@ -42,9 +41,8 @@ builds:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm64]
hooks:
post:
- upx "{{ .Path }}" # apt install upx
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
nfpms:
-
package_name: ntfy

View File

@@ -2,4 +2,6 @@ FROM alpine
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
COPY ntfy /usr/bin
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

View File

@@ -34,7 +34,14 @@ too.
[Building](https://ntfy.sh/docs/develop/)
## Contributing
I welcome any and all contributions. Just create a PR or an issue.
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
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
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`:
* `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`
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)
* `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
accepted (which may obviously be a spam problem).
@@ -369,6 +369,42 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
<figcaption>DNS records for incoming mail</figcaption>
</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.)
!!! warning
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are

View File

@@ -10,6 +10,10 @@ 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
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>`
> Active since 2022-02-27, behavior will change in **May 2022**

View File

@@ -132,186 +132,213 @@ Some simple bash scripts to achieve this are kindly provided in [nickexyz's repo
## Node-RED
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
<details>
<summary>Example: Send a message</summary>
<summary>Example: Send a message (click to expand)</summary>
```
[
{
"id": "8f09d37dd5773f88",
"type": "http request",
"z": "ff3ad4e1.d3415",
"name": "ntfy",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://example.com/topic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials": {},
"x": 1410,
"y": 740,
"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;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1290,
"y": 740,
"wires": [
[
"8f09d37dd5773f88"
]
]
},
{
"id": "d2351ed0720a239f",
"type": "inject",
"z": "ff3ad4e1.d3415",
"name": "Manual start",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 1150,
"y": 740,
"wires": [
[
"2603f296b25fe351"
]
]
}
{
"id": "c956e688cc74ad8e",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "ntfy.sh",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://ntfy.sh/mytopic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 590,
"y": 3160,
"wires":
[
[]
]
},
{
"id": "32ee1eade51fae50",
"type": "function",
"z": "fabdd7a3.4045a",
"name": "data",
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 3160,
"wires":
[
[
"c956e688cc74ad8e"
]
]
},
{
"id": "b287e59cd2311815",
"type": "inject",
"z": "fabdd7a3.4045a",
"name": "Manual start",
"props":
[
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 330,
"y": 3160,
"wires":
[
[
"32ee1eade51fae50"
]
]
}
]
```
</details>
![Node red message flow](static/img/nodered-message.png)
<details>
<summary>Example: Send a picture</summary>
<summary>Example: Send a picture (click to expand)</summary>
```
[
{
"id": "726d0d75d6c0f70e",
"type": "http request",
"z": "ff3ad4e1.d3415",
"name": "Download jpeg",
"method": "GET",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials": {},
"x": 1320,
"y": 780,
"wires": [
[
"730dbbc9dbf1ed8a"
]
]
},
{
"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;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1470,
"y": 780,
"wires": [
[
"592f424b37f76f5c"
]
]
},
{
"id": "592f424b37f76f5c",
"type": "http request",
"z": "ff3ad4e1.d3415",
"name": "ntfy",
"method": "PUT",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://example.com/topic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"x": 1590,
"y": 780,
"wires": [
[]
]
},
{
"id": "8aa06dda3c902f6a",
"type": "inject",
"z": "ff3ad4e1.d3415",
"name": "Manual start",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 1150,
"y": 780,
"wires": [
[
"726d0d75d6c0f70e"
]
]
}
{
"id": "d135a13eadeb9d6d",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "Download image",
"method": "GET",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 490,
"y": 3320,
"wires":
[
[
"6e75bc41d2ec4a03"
]
]
},
{
"id": "6e75bc41d2ec4a03",
"type": "function",
"z": "fabdd7a3.4045a",
"name": "data",
"func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 650,
"y": 3320,
"wires":
[
[
"eb160615b6ceda98"
]
]
},
{
"id": "eb160615b6ceda98",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "ntfy.sh",
"method": "PUT",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://ntfy.sh/mytopic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 770,
"y": 3320,
"wires":
[
[]
]
},
{
"id": "5b8dbf15c8a7a3a5",
"type": "inject",
"z": "fabdd7a3.4045a",
"name": "Manual start",
"props":
[
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 310,
"y": 3320,
"wires":
[
[
"d135a13eadeb9d6d"
]
]
}
]
```
</details>
![Node red picture flow](static/img/nodered-picture.png)
## Gatus service health check
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"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_x86_64.tar.gz
tar zxvf ntfy_1.18.1_linux_x86_64.tar.gz
sudo cp -a ntfy_1.18.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_x86_64.tar.gz
tar zxvf ntfy_1.20.0_linux_x86_64.tar.gz
sudo cp -a ntfy_1.20.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_armv7.tar.gz
tar zxvf ntfy_1.18.1_linux_armv7.tar.gz
sudo cp -a ntfy_1.18.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.1_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.tar.gz
tar zxvf ntfy_1.20.0_linux_armv7.tar.gz
sudo cp -a ntfy_1.20.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_arm64.tar.gz
tar zxvf ntfy_1.18.1_linux_arm64.tar.gz
sudo cp -a ntfy_1.18.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.18.1_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.tar.gz
tar zxvf ntfy_1.20.0_linux_arm64.tar.gz
sudo cp -a ntfy_1.20.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -94,7 +94,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -102,7 +102,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -110,7 +110,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -120,21 +120,21 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.1/ntfy_1.18.1_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -152,7 +152,6 @@ cd ntfysh-bin
makepkg -si
```
## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
straight forward to use.
@@ -187,6 +186,24 @@ docker run \
serve
```
Using docker-compose:
```yaml
version: "2.1"
services:
ntfy:
image: binwiederhier/ntfy
container_name: ntfy
command:
- serve
volumes:
- /var/cache/ntfy:/var/cache/ntfy
- /etc/ntfy:/etc/ntfy
ports:
- 80:80
restart: unless-stopped
```
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
```
FROM binwiederhier/ntfy
@@ -194,13 +211,3 @@ COPY server.yml /etc/ntfy/server.yml
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.
## 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,7 +661,8 @@ 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
POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
Here's an example using all supported parameters. The `topic` parameter is the only required one:
Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter
is the only required one:
=== "Command line (curl)"
```
@@ -798,7 +799,8 @@ all the supported fields:
| `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) |
| `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
You can define which URL to open when a notification is clicked. This may be useful if your notification is related

View File

@@ -9,13 +9,74 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Features:**
* 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))
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
Bugs:
**Bugs:**
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
* 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)
* Add priority strings to strings.xml to make it translatable (#192, thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
**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 (thanks to [@Kusoneko](https://kusoneko.moe/) and [@mlcsthor](https://hosted.weblate.org/user/mlcsthor/))
* German (thanks to [@cmeis](https://github.com/cmeis))
* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/))
* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))
* 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:**
* 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
-->
## ntfy server v1.20.0
Released Apr 6, 2022
**Features:**:
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
**Bugs:**
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@RasHas](https://github.com/RasHas))
**Documentation:**
* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@RasHas](https://github.com/RasHas))
**Integrations:**
* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524),
thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work)
## ntfy server v1.19.0
Released Mar 30, 2022
**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)
* 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
Released Mar 21, 2022
_This release ships no features or bug fixes. It's merely a documentation update._

BIN
docs/static/img/nodered-message.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
docs/static/img/nodered-picture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

16
go.mod
View File

@@ -6,7 +6,7 @@ require (
cloud.google.com/go/firestore v1.6.1 // indirect
cloud.google.com/go/storage v1.21.0 // indirect
firebase.google.com/go v3.13.0+incompatible
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/gabriel-vasile/mimetype v1.4.0
@@ -15,12 +15,12 @@ require (
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.4.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
google.golang.org/api v0.73.0
google.golang.org/api v0.74.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -28,7 +28,7 @@ require (
cloud.google.com/go v0.100.2 // indirect
cloud.google.com/go/compute v1.5.0 // indirect
cloud.google.com/go/iam v0.3.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -39,13 +39,13 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect
google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

32
go.sum
View File

@@ -61,11 +61,14 @@ cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKu
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -244,8 +247,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s=
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -317,9 +323,13 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -406,8 +416,12 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -522,6 +536,8 @@ google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/S
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
google.golang.org/api v0.73.0 h1:O9bThUh35K1rvUrQwTUQ1eqLC/IYyzUpWavYIO2EXvo=
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -604,8 +620,11 @@ google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE=
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220322021311-435b647f9ef2 h1:3n0D2NdPGm0g0wrVJzXJWW5CBOoqgGBkDX9cRMJHZAY=
google.golang.org/genproto v0.0.0-20220322021311-435b647f9ef2/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf h1:JTjwKJX9erVpsw17w+OIPP7iAgEkN/r8urhWSunEDTs=
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -648,8 +667,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,9 +1,3 @@
# The documentation uses 'mkdocs', which is written in Python
# See https://github.com/squidfunk/mkdocs-material/issues/2030
jinja2>=2.11.1
# mkdocs
mkdocs
mkdocs-material
mkdocs-minify-plugin

View File

@@ -10,7 +10,7 @@ if [ -z "$1" ]; then
echo "Syntax: $0 FILE.(js|json|md)"
echo "Example:"
echo " $0 emoji-converted.json"
echo " $0 $ROOTDIR/server/static/js/emoji.js"
echo " $0 $ROOTDIR/web/src/app/emojis.js"
echo " $0 $ROOTDIR/docs/emojis.md"
exit 1
fi
@@ -19,7 +19,7 @@ if [[ "$1" == *.js ]]; then
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export const rawEmojis = " > "$1"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji, aliases: .aliases, tags: .tags, category: .category, description: .description, unicode_version: .unicode_version})' >> "$1"
elif [[ "$1" == *.md ]]; then
echo "# Emoji reference

View File

@@ -34,7 +34,6 @@ var (
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
@@ -43,6 +42,7 @@ var (
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "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"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}

View File

@@ -355,7 +355,7 @@ func (c *messageCache) Prune(olderThan time.Time) error {
return err
}
func (c *messageCache) AttachmentsSize(owner string) (int64, error) {
func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
if err != nil {
return 0, err

View File

@@ -337,11 +337,11 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
size, err := c.AttachmentsSize("1.2.3.4")
size, err := c.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(30000), size)
size, err = c.AttachmentsSize("5.6.7.8")
size, err = c.AttachmentBytesUsed("5.6.7.8")
require.Nil(t, err)
require.Equal(t, int64(0), size)

View File

@@ -55,17 +55,18 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
var (
// If changed, don't forget to update Android App and auth_sqlite.go
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!
extTopicPathRegex = 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$`)
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$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
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!
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$`)
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$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
userStatsPath = "/user/stats"
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@@ -269,6 +270,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleEmpty(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.handleWebConfig(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
@@ -293,7 +296,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || extTopicPathRegex.MatchString(r.URL.Path)) {
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
return s.handleTopic(w, r)
}
return errHTTPNotFound
@@ -351,6 +354,19 @@ var config = {
return err
}
func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error {
stats, err := v.Stats()
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(stats); err != nil {
return err
}
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@@ -380,6 +396,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
return errHTTPTooManyRequestsAttachmentBandwidthLimit
}
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)
if err != nil {
return err
@@ -394,7 +411,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if err != nil {
return err
}
body, err := util.Peak(r.Body, s.config.MessageLimit)
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
@@ -538,35 +555,35 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is > message limit, treat it as an attachment
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, unifiedpush bool) error {
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
} else if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsTextMessage(m, body) // Case 2
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 4
}
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
}
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
if utf8.Valid(body.PeakedBytes) {
m.Message = string(body.PeakedBytes) // Do not trim
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
if utf8.Valid(body.PeekedBytes) {
m.Message = string(body.PeekedBytes) // Do not trim
} else {
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
m.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)
m.Encoding = encodingBase64
}
return nil
}
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error {
if !utf8.Valid(body.PeakedBytes) {
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
if !utf8.Valid(body.PeekedBytes) {
return errHTTPBadRequestMessageNotUTF8
}
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!)
m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
}
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
@@ -574,22 +591,21 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser
return nil
}
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
return errHTTPBadRequestAttachmentsDisallowed
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
}
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip)
visitorStats, err := v.Stats()
if err != nil {
return err
}
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
contentLengthStr := r.Header.Get("Content-Length")
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPBadRequestAttachmentTooLarge
if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPEntityTooLargeAttachmentTooLarge
}
}
if m.Attachment == nil {
@@ -598,7 +614,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
var ext string
m.Attachment.Owner = v.ip // Important for attachment rate limiting
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name)
m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
if m.Attachment.Name == "" {
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
@@ -606,9 +622,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
if m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
}
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining))
if err == util.ErrLimitReached {
return errHTTPBadRequestAttachmentTooLarge
return errHTTPEntityTooLargeAttachmentTooLarge
} else if err != nil {
return err
}
@@ -1095,11 +1111,11 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
}
}
// transformBodyJSON peaks the request body, reads the JSON, and converts it to headers
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
body, err := util.Peak(r.Body, s.config.MessageLimit)
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
@@ -1134,6 +1150,12 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if 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)
}
}
@@ -1209,7 +1231,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
}
v, exists := s.visitors[ip]
if !exists {
s.visitors[ip] = newVisitor(s.config, ip)
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
return s.visitors[ip]
}
v.Keepalive()

View File

@@ -203,6 +203,14 @@ func TestServer_PublishPriority(t *testing.T) {
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
// This tests a bug that allowed publishing topics with a comma in the name (no ticket)
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil)
require.Equal(t, 404, response.Code)
}
func TestServer_PublishNoCache(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -706,6 +714,12 @@ func (t *testMailer) Send(from, to string, m *message) error {
return nil
}
func (t *testMailer) Count() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.count
}
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for i := 0; i < 60; i++ {
@@ -865,7 +879,8 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
func TestServer_PublishAsJSON(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
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)
require.Equal(t, 200, response.Code)
@@ -878,6 +893,22 @@ func TestServer_PublishAsJSON(t *testing.T) {
require.Equal(t, "google.pdf", m.Attachment.Name)
require.Equal(t, "http://ntfy.sh", m.Click)
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) {
@@ -907,7 +938,7 @@ func TestServer_PublishAttachment(t *testing.T) {
require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request()
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
require.Nil(t, err)
require.Equal(t, int64(5000), size)
}
@@ -936,7 +967,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentsSize("1.2.3.4")
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(21), size)
}
@@ -956,7 +987,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
require.Equal(t, "", msg.Attachment.Owner)
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
size, err := s.messageCache.AttachmentsSize("127.0.0.1")
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
require.Nil(t, err)
require.Equal(t, int64(0), size)
}
@@ -993,9 +1024,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
"Content-Length": "20000000",
})
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
@@ -1005,9 +1036,9 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.
s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
@@ -1037,9 +1068,9 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
content := util.RandomString(5001) // 5000+5001 > , see below
response = request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
@@ -1113,8 +1144,32 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
// And a failed one
response := request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentUserStats(t *testing.T) {
content := util.RandomString(4999) // > 4096
c := newTestConfig(t)
c.AttachmentFileSizeLimit = 5000
c.VisitorAttachmentTotalSizeLimit = 6000
s := newTestServer(t, c)
// Upload one attachment
response := request(t, s, "PUT", "/mytopic", content, nil)
msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
// User stats
response = request(t, s, "GET", "/user/stats", "", nil)
require.Equal(t, 200, response.Code)
var stats visitorStats
require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats))
require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit)
require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal)
require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed)
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
}
func newTestConfig(t *testing.T) *Config {

View File

@@ -52,6 +52,8 @@ type publishMessage struct {
Click string `json:"click"`
Attach string `json:"attach"`
Filename string `json:"filename"`
Email string `json:"email"`
Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message

View File

@@ -22,6 +22,7 @@ var (
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct {
config *Config
messageCache *messageCache
ip string
requests *rate.Limiter
emails *rate.Limiter
@@ -31,9 +32,17 @@ type visitor struct {
mu sync.Mutex
}
func newVisitor(conf *Config, ip string) *visitor {
type visitorStats struct {
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
}
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
return &visitor{
config: conf,
messageCache: messageCache,
ip: ip,
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
@@ -91,3 +100,20 @@ func (v *visitor) Stale() bool {
defer v.mu.Unlock()
return time.Since(v.seen) > visitorExpungeAfter
}
func (v *visitor) Stats() (*visitorStats, error) {
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
if err != nil {
return nil, err
}
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
if attachmentsBytesRemaining < 0 {
attachmentsBytesRemaining = 0
}
return &visitorStats{
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
}, nil
}

View File

@@ -1,61 +0,0 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory.
// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully
// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeakedReadCloser struct {
PeakedBytes []byte
LimitReached bool
peaked io.Reader
underlying io.ReadCloser
closed bool
}
// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser
func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peaked := make([]byte, limit)
read, err := io.ReadFull(underlying, peaked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeakedReadCloser{
PeakedBytes: peaked[:read],
LimitReached: read == limit,
underlying: underlying,
peaked: bytes.NewReader(peaked[:read]),
closed: false,
}, nil
}
// Read reads from the peaked bytes and then from the underlying stream
func (r *PeakedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peaked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeakedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

61
util/peek.go Normal file
View File

@@ -0,0 +1,61 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.
// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully
// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeekedReadCloser struct {
PeekedBytes []byte
LimitReached bool
peeked io.Reader
underlying io.ReadCloser
closed bool
}
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peeked := make([]byte, limit)
read, err := io.ReadFull(underlying, peeked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeekedReadCloser{
PeekedBytes: peeked[:read],
LimitReached: read == limit,
underlying: underlying,
peeked: bytes.NewReader(peeked[:read]),
closed: false,
}, nil
}
// Read reads from the peeked bytes and then from the underlying stream
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peeked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeekedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

View File

@@ -9,11 +9,11 @@ import (
func TestPeak_LimitReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peak(underlying, 5)
peaked, err := Peek(underlying, 5)
if err != nil {
t.Fatal(err)
}
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, true, peaked.LimitReached)
all, err := io.ReadAll(peaked)
@@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, true, peaked.LimitReached)
}
func TestPeak_LimitNotReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peak(underlying, 15)
peaked, err := Peek(underlying, 15)
if err != nil {
t.Fatal(err)
}
@@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes)
require.Equal(t, []byte("1234567890"), peaked.PeekedBytes)
require.Equal(t, false, peaked.LimitReached)
}
func TestPeak_Nil(t *testing.T) {
peaked, err := Peak(nil, 15)
peaked, err := Peek(nil, 15)
if err != nil {
t.Fatal(err)
}
@@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte(""), all)
require.Equal(t, []byte(""), peaked.PeakedBytes)
require.Equal(t, []byte(""), peaked.PeekedBytes)
require.Equal(t, false, peaked.LimitReached)
}

2890
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ html {
}
a, a:visited {
color: #3a9784;
color: #338574;
}
a:hover {
@@ -114,7 +114,7 @@ code {
}
.anchor .anchorLink:hover {
color: #3a9784;
color: #338574;
visibility: visible;
}
@@ -221,7 +221,7 @@ figcaption {
/* Header */
#header {
background: #3a9784;
background: #338574;
height: 130px;
}

View File

@@ -1,11 +1,13 @@
import {
basicAuth,
encodeBase64,
fetchLinesIterator,
maybeWithBasicAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince
topicUrlJsonPollWithSince, userStatsUrl
} from "./utils";
import userManager from "./UserManager";
@@ -26,25 +28,78 @@ class Api {
return messages;
}
async publish(baseUrl, topic, message, title, priority, tags) {
async publish(baseUrl, topic, message, options) {
const user = await userManager.get(baseUrl);
const url = topicUrl(baseUrl, topic);
console.log(`[Api] Publishing message to ${url}`);
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const headers = {};
if (title) {
headers["X-Title"] = title;
}
if (priority !== 3) {
headers["X-Priority"] = `${priority}`;
}
if (tags.length > 0) {
headers["X-Tags"] = tags.join(",");
}
await fetch(url, {
const body = {
topic: topic,
message: message,
...options
};
const response = await fetch(baseUrl, {
method: 'PUT',
body: message,
body: JSON.stringify(body),
headers: maybeWithBasicAuth(headers, user)
});
if (response.status < 200 || response.status > 299) {
throw new Error(`Unexpected response: ${response.status}`);
}
return response;
}
/**
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
*
* Firefox XHR bug:
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
* correct headers are clearly set. It's quite the odd behavior.
*
* There is an example, and the bug report here:
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
*/
publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) {
xhr.open("PUT", url);
if (body.type) {
xhr.overrideMimeType(body.type);
}
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener('readystatechange', (ev) => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
resolve(xhr.response);
} else if (xhr.readyState === 4) {
// Firefox bug; see description above!
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
let errorText;
try {
const error = JSON.parse(xhr.responseText);
if (error.code && error.error) {
errorText = `Error ${error.code}: ${error.error}`;
}
} catch (e) {
// Nothing
}
xhr.abort();
reject(errorText ?? "An error occurred");
}
})
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
}
return send;
}
async auth(baseUrl, topic, user) {
@@ -62,6 +117,18 @@ class Api {
}
throw new Error(`Unexpected server response ${response.status}`);
}
async userStats(baseUrl) {
const url = userStatsUrl(baseUrl);
console.log(`[Api] Fetching user stats ${url}`);
const response = await fetch(url);
if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const stats = await response.json();
console.log(`[Api] Stats`, stats);
return stats;
}
}
const api = new Api();

View File

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

View File

@@ -1,7 +1,7 @@
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
const delayMillis = 15000; // 15 seconds
const delayMillis = 25000; // 25 seconds
const intervalMillis = 1800000; // 30 minutes
class Pruner {
@@ -35,6 +35,4 @@ class Pruner {
}
const pruner = new Pruner();
pruner.startWorker();
export default pruner;

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,7 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
@@ -115,6 +116,13 @@ export const shuffle = (arr) => {
return arr;
}
export const splitNoEmpty = (s, delimiter) => {
return s
.split(delimiter)
.map(x => x.trim())
.filter(x => x !== "");
}
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
let hash = 0;

View File

@@ -105,7 +105,7 @@ const SettingsIcons = (props) => {
}
};
const handleSendTestMessage = () => {
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
@@ -121,18 +121,29 @@ const SettingsIcons = (props) => {
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!"
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
])[0];
const nowSeconds = Math.round(Date.now()/1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(Date.now())} right now. Is that early or late?`,
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(Date.now())} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
])[0];
api.publish(baseUrl, topic, message, title, priority, tags);
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
});
} catch (e) {
console.log(`[ActionBar] Error publishing message`, e);
}
setOpen(false);
}

View File

@@ -17,9 +17,10 @@ import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from
import {expandUrl} from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
import SendDialog from "./SendDialog";
import Messaging from "./Messaging";
// TODO add drag and drop
// TODO races when two tabs are open
// TODO investigate service workers
@@ -58,6 +59,7 @@ const Layout = () => {
const params = useParams();
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -67,7 +69,7 @@ const Layout = () => {
});
useConnectionListeners(subscriptions, users);
useLocalStorageMigration();
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
@@ -84,11 +86,17 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(SendDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar/>
<Outlet context={{ subscriptions, selected }}/>
</Main>
<Messaging
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import Box from "@mui/material/Box";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
const AttachmentIcon = (props) => {
const type = props.type;
let imageFile;
if (!type) {
imageFile = fileDocument;
} else if (type.startsWith('image/')) {
imageFile = fileImage;
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
} else {
imageFile = fileDocument;
}
return (
<Box
component="img"
src={imageFile}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
);
}
export default AttachmentIcon;

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import Box from "@mui/material/Box";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
const DialogFooter = (props) => {
return (
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingBottom: '8px',
}}>
<DialogContentText component="div" sx={{
margin: '0px',
paddingTop: '12px',
paddingBottom: '4px'
}}>
{props.status}
</DialogContentText>
<DialogActions sx={{paddingRight: 2}}>
{props.children}
</DialogActions>
</Box>
);
};
export default DialogFooter;

View File

@@ -0,0 +1,169 @@
import * as React from 'react';
import {useRef, useState} from 'react';
import Typography from '@mui/material/Typography';
import {rawEmojis} from '../app/emojis';
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Close} from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import {splitNoEmpty} from "../app/utils";
// Create emoji list by category and create a search base (string with all search words)
//
// This also filters emojis that are not supported by Desktop Chrome.
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {};
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach(emoji => {
if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = [];
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
} catch (e) {
// Nothing. Ignore.
}
});
const EmojiPicker = (props) => {
const open = Boolean(props.anchorEl);
const [search, setSearch] = useState("");
const searchRef = useRef(null);
const searchFields = splitNoEmpty(search.toLowerCase(), " ");
const handleSearchClear = () => {
setSearch("");
searchRef.current?.focus();
};
return (
<Popper
open={open}
anchorEl={props.anchorEl}
placement="bottom-start"
sx={{ zIndex: 10005 }}
transition
>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
<Box sx={{
boxShadow: 3,
padding: 2,
paddingRight: 0,
paddingBottom: 1,
width: "380px",
maxHeight: "300px",
backgroundColor: 'background.paper',
overflowY: "scroll"
}}>
<TextField
inputRef={searchRef}
margin="dense"
size="small"
placeholder="Search emoji"
value={search}
onChange={ev => setSearch(ev.target.value)}
type="text"
variant="standard"
fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
InputProps={{
endAdornment:
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
<IconButton size="small" onClick={handleSearchClear} edge="end"><Close/></IconButton>
</InputAdornment>
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
{Object.keys(emojisByCategory).map(category =>
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
)}
</Box>
</Box>
</Fade>
</ClickAwayListener>
)}
</Popper>
);
};
const Category = (props) => {
const showTitle = props.search.length === 0;
return (
<>
{showTitle &&
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title}
</Typography>
}
{props.emojis.map(emoji =>
<Emoji
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
)}
</>
);
};
const Emoji = (props) => {
const emoji = props.emoji;
const matches = emojiMatches(emoji, props.search);
return (
<EmojiDiv
onClick={props.onClick}
title={`${emoji.description} (${emoji.aliases[0]})`}
style={{ display: (matches) ? '' : 'none' }}
>
{props.emoji.emoji}
</EmojiDiv>
);
};
const EmojiDiv = styled("div")({
fontSize: "30px",
width: "30px",
height: "30px",
marginTop: "8px",
marginBottom: "8px",
marginRight: "8px",
lineHeight: "30px",
cursor: "pointer",
opacity: 0.85,
"&:hover": {
opacity: 1
}
});
const emojiMatches = (emoji, words) => {
if (words.length === 0) {
return true;
}
for (const word of words) {
if (emoji.searchBase.indexOf(word) === -1) {
return false;
}
}
return true;
}
export default EmojiPicker;

View File

@@ -0,0 +1,111 @@
import * as React from 'react';
import {useState} from 'react';
import Navigation from "./Navigation";
import {topicUrl} from "../app/utils";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import SendDialog from "./SendDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {Portal, Snackbar} from "@mui/material";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected;
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT);
};
const handleSendDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1);
};
return (
<>
{subscription && <MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>}
<SendDialog
key={`sendDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? window.location.origin}
topic={subscription?.topic ?? ""}
message={message}
onClose={handleSendDialogClose}
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : SendDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT)}
/>
</>
);
}
const MessageBar = (props) => {
const subscription = props.subscription;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(subscription.baseUrl, subscription.topic, props.message);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: 'fixed',
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick}>
<KeyboardArrowUpIcon/>
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder="Type a message here"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSendClick();
}
}}
/>
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick}>
<SendIcon/>
</IconButton>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message="Error publishing message"
/>
</Portal>
</Paper>
);
};
export default Messaging;

View File

@@ -19,7 +19,7 @@ import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material";
import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
@@ -118,9 +118,13 @@ const NavList = (props) => {
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemText primary="Documentation"/>
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemText primary="Publish message"/>
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary="Add subscription"/>
<ListItemText primary="Subscribe to topic"/>
</ListItemButton>
</List>
<SubscribeDialog

View File

@@ -20,7 +20,8 @@ import {
formatMessage,
formatShortDateTime,
formatTitle,
openUrl, shortUrl,
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags
} from "../app/utils";
@@ -32,16 +33,12 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
import InfiniteScroll from "react-infinite-scroll-component";
import fileApp from "../img/file-app.svg";
import fileAudio from "../img/file-audio.svg";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
const Notifications = (props) => {
if (props.mode === "all") {
@@ -60,7 +57,7 @@ const AllSubscriptions = (props) => {
} else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
}
return <NotificationList key="all" notifications={notifications}/>;
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
}
const SingleSubscription = (props) => {
@@ -71,7 +68,7 @@ const SingleSubscription = (props) => {
} else if (notifications.length === 0) {
return <NoNotifications subscription={subscription}/>;
}
return <NotificationList id={subscription.id} notifications={notifications}/>;
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
}
const NotificationList = (props) => {
@@ -97,7 +94,13 @@ const NotificationList = (props) => {
scrollThreshold={0.7}
scrollableTarget="main"
>
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Container
maxWidth="md"
sx={{
marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
}}
>
<Stack spacing={3}>
{notifications.slice(0, count).map(notification =>
<NotificationItem
@@ -119,13 +122,12 @@ const NotificationList = (props) => {
const NotificationItem = (props) => {
const notification = props.notification;
const subscriptionId = notification.subscriptionId;
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id)
}
const handleCopy = (s) => {
@@ -239,7 +241,7 @@ const Attachment = (props) => {
padding: 1,
borderRadius: '4px',
}}>
<Icon type={attachment.type}/>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
@@ -268,7 +270,7 @@ const Attachment = (props) => {
}
}}
>
<Icon type={attachment.type}/>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
@@ -323,35 +325,6 @@ const Image = (props) => {
);
}
const Icon = (props) => {
const type = props.type;
let imageFile;
if (!type) {
imageFile = fileDocument;
} else if (type.startsWith('image/')) {
imageFile = fileImage;
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
} else {
imageFile = fileDocument;
}
return (
<Box
component="img"
src={imageFile}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
);
}
const NoNotifications = (props) => {
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (

View File

@@ -0,0 +1,655 @@
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {NotificationItem} from "./Notifications";
import theme from "./theme";
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
const SendDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [message, setMessage] = useState("");
const [messageFocused, setMessageFocused] = useState(true);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [priority, setPriority] = useState(3);
const [clickUrl, setClickUrl] = useState("");
const [attachUrl, setAttachUrl] = useState("");
const [attachFile, setAttachFile] = useState(null);
const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState("");
const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false);
const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl;
const attachFileInput = useRef();
const [attachFileError, setAttachFileError] = useState("");
const [activeRequest, setActiveRequest] = useState(null);
const [status, setStatus] = useState("");
const disabled = !!activeRequest;
const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
const [dropZone, setDropZone] = useState(false);
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => {
window.addEventListener('dragenter', () => {
props.onDragEnter();
setDropZone(true);
});
}, []);
useEffect(() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(!props.baseUrl || !props.topic);
setMessageFocused(!!props.topic); // Focus message only if topic is set
}, [props.baseUrl, props.topic]);
useEffect(() => {
const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
setSendButtonEnabled(valid);
}, [baseUrl, topic, attachFileError]);
useEffect(() => {
setMessage(props.message);
}, [props.message]);
const handleSubmit = async () => {
const url = new URL(topicUrl(baseUrl, topic));
if (title.trim()) {
url.searchParams.append("title", title.trim());
}
if (tags.trim()) {
url.searchParams.append("tags", tags.trim());
}
if (priority && priority !== 3) {
url.searchParams.append("priority", priority.toString());
}
if (clickUrl.trim()) {
url.searchParams.append("click", clickUrl.trim());
}
if (attachUrl.trim()) {
url.searchParams.append("attach", attachUrl.trim());
}
if (filename.trim()) {
url.searchParams.append("filename", filename.trim());
}
if (email.trim()) {
url.searchParams.append("email", email.trim());
}
if (delay.trim()) {
url.searchParams.append("delay", delay.trim());
}
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
}
const body = (attachFile) ? attachFile : message;
try {
const user = await userManager.get(baseUrl);
const headers = maybeWithBasicAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
const percent = Math.round(ev.loaded * 100.0 / ev.total);
setStatus(`Uploading ${formatBytes(ev.loaded)}/${formatBytes(ev.total)} (${percent}%) ...`);
} else {
setStatus(`Uploading ...`);
}
};
const request = api.publishXHR(url, body, headers, progressFn);
setActiveRequest(request);
await request;
if (!publishAnother) {
props.onClose();
} else {
setStatus("Message published");
setActiveRequest(null);
}
} catch (e) {
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
setActiveRequest(null);
}
};
const checkAttachmentLimits = async (file) => {
try {
const stats = await api.userStats(baseUrl);
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit and quota, ${formatBytes(remainingBytes)} remaining`);
} else if (fileSizeLimitReached) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit`);
} else if (quotaReached) {
return setAttachFileError(`exceeds quota, ${formatBytes(remainingBytes)} remaining`);
}
setAttachFileError("");
} catch (e) {
console.log(`[SendDialog] Retrieving attachment limits failed`, e);
setAttachFileError(""); // Reset error (rely on server-side checking)
}
};
const handleAttachFileClick = () => {
attachFileInput.current.click();
};
const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]);
};
const handleAttachFileDrop = async (ev) => {
ev.preventDefault();
setDropZone(false);
await updateAttachFile(ev.dataTransfer.files[0]);
};
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileDragLeave = () => {
setDropZone(false);
if (props.openMode === SendDialog.OPEN_MODE_DRAG) {
props.onClose(); // Only close dialog if it was not open before dragging file in
}
};
const handleEmojiClick = (ev) => {
setEmojiPickerAnchorEl(ev.currentTarget);
};
const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
};
const handleEmojiClose = () => {
setEmojiPickerAnchorEl(null);
};
return (
<>
{dropZone && <DropArea
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? `Publish to ${topicShortUrl(baseUrl, topic)}` : "Publish message"}</DialogTitle>
<DialogContent>
{dropZone && <DropBox/>}
{showTopicUrl &&
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}>
<TextField
margin="dense"
label="Server URL"
placeholder="Server URL, e.g. https://example.com"
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
/>
<TextField
margin="dense"
label="Topic"
placeholder="Topic name, e.g. phil_alerts"
value={topic}
onChange={ev => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{flexGrow: 1}}
/>
</ClosableRow>
}
<TextField
margin="dense"
label="Title"
value={title}
onChange={ev => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
placeholder="Notification title, e.g. Disk space alert"
/>
<TextField
margin="dense"
label="Message"
placeholder="Type a message here"
value={message}
onChange={ev => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
rows={5}
autoFocus={messageFocused}
fullWidth
multiline
/>
<div style={{display: 'flex'}}>
<EmojiPicker
anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick}>
<InsertEmoticonIcon/>
</DialogIconButton>
<TextField
margin="dense"
label="Tags"
placeholder="Comma-separated list of tags, e.g. warning, srv1-backup"
value={tags}
onChange={ev => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
/>
<FormControl
variant="standard"
margin="dense"
sx={{minWidth: 120, maxWidth: 200, flexGrow: 1}}
>
<InputLabel/>
<Select
label="Priority"
margin="dense"
value={priority}
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
>
{[5,4,3,2,1].map(priority =>
<MenuItem key={`priorityMenuItem${priority}`} value={priority}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={priorities[priority].file} style={{marginRight: "8px"}}/>
<div>{priorities[priority].label}</div>
</div>
</MenuItem>
)}
</Select>
</FormControl>
</div>
{showClickUrl &&
<ClosableRow disabled={disabled} onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}>
<TextField
margin="dense"
label="Click URL"
placeholder="URL that is opened when notification is clicked"
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
/>
</ClosableRow>
}
{showEmail &&
<ClosableRow disabled={disabled} onClose={() => {
setEmail("");
setShowEmail(false);
}}>
<TextField
margin="dense"
label="Email"
placeholder="Address to forward the message to, e.g. phil@example.com"
value={email}
onChange={ev => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
/>
</ClosableRow>
}
{showAttachUrl &&
<ClosableRow disabled={disabled} onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}>
<TextField
margin="dense"
label="Attachment URL"
placeholder="Attach file by URL, e.g. https://f-droid.org/F-Droid.apk"
value={attachUrl}
onChange={ev => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
try {
const u = new URL(url);
const parts = u.pathname.split("/");
if (parts.length > 0) {
setFilename(parts[parts.length-1]);
}
} catch (e) {
// Do nothing
}
}
}}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 5, marginRight: 1}}
/>
<TextField
margin="dense"
label="Filename"
placeholder="Attachment filename"
value={filename}
onChange={ev => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1}}
/>
</ClosableRow>
}
<input
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: 'none' }}
/>
{showAttachFile && <AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
error={attachFileError}
onChangeFilename={(f) => setFilename(f)}
onClose={() => {
setAttachFile(null);
setAttachFileError("");
setFilename("");
}}
/>}
{showDelay &&
<ClosableRow disabled={disabled} onClose={() => {
setDelay("");
setShowDelay(false);
}}>
<TextField
margin="dense"
label="Delay"
placeholder="Delay delivery, e.g. 1649029748, 30m, or tomorrow, 9am"
value={delay}
onChange={ev => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
/>
</ClosableRow>
}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
Other features:
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label="Click URL" onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label="Forward to email" onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label="Attach file by URL" onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label="Attach local file" onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label="Delay delivery" onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label="Change topic" onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
</div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
For examples and a detailed description of all send features, please
refer to the <Link href="/docs" target="_blank">documentation</Link>.
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>Cancel sending</Button>}
{!activeRequest &&
<>
<FormControlLabel
label="Publish another"
sx={{marginRight: 2}}
control={
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
} />
<Button onClick={props.onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>Send</Button>
</>
}
</DialogFooter>
</Dialog>
</>
);
};
const Row = (props) => {
return (
<div style={{display: 'flex'}}>
{props.children}
</div>
);
};
const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
return (
<Row>
{props.children}
{closable && <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>}
</Row>
);
};
const DialogIconButton = (props) => {
const sx = props.sx || {};
return (
<IconButton
color="inherit"
size="large"
edge="start"
sx={{height: "45px", marginTop: "17px", ...sx}}
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</IconButton>
);
};
const AttachmentBox = (props) => {
const file = props.file;
return (
<>
<Typography variant="body1" sx={{marginTop: 2}}>
Attached file:
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
padding: 0.5,
borderRadius: '4px',
}}>
<AttachmentIcon type={file.type}/>
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
<ExpandingTextField
minWidth={140}
variant="body2"
value={props.filename}
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
/>
<br/>
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }}>
{" "}({props.error})
</Typography>
}
</Typography>
</Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
</Box>
</>
);
};
const ExpandingTextField = (props) => {
const invisibleFieldRef = useRef();
const [textWidth, setTextWidth] = useState(props.minWidth);
const determineTextWidth = () => {
const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
if (!boundingRect) {
return props.minWidth;
}
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
};
useEffect(() => {
setTextWidth(determineTextWidth() + 5);
}, [props.value]);
return (
<>
<Typography
ref={invisibleFieldRef}
component="span"
variant={props.variant}
sx={{position: "absolute", left: "-200%"}}
>
{props.value}
</Typography>
<TextField
margin="dense"
placeholder="Attachment filename"
value={props.value}
onChange={props.onChange}
type="text"
variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
inputProps={{ style: { paddingBottom: 0, paddingTop: 0 } }}
disabled={props.disabled}
/>
</>
)
};
const DropArea = (props) => {
const allowDrag = (ev) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
ev.dataTransfer.dropEffect = 'copy';
ev.preventDefault();
};
return (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10002,
}}
onDrop={props.onDrop}
onDragEnter={allowDrag}
onDragOver={allowDrag}
onDragLeave={props.onDragLeave}
/>
);
};
const DropBox = () => {
return (
<Box sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb"
}}>
<Box
sx={{
position: 'absolute',
border: '3px dashed #ccc',
borderRadius: '5px',
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: 'flex',
justifyContent: "center",
alignItems: "center",
}}
>
<Typography variant="h5">Drop file here</Typography>
</Box>
</Box>
);
}
const priorities = {
1: { label: "Min. priority", file: priority1 },
2: { label: "Low priority", file: priority2 },
3: { label: "Default priority", file: priority3 },
4: { label: "High priority", file: priority4 },
5: { label: "Max. priority", file: priority5 }
};
SendDialog.OPEN_MODE_DEFAULT = "default";
SendDialog.OPEN_MODE_DRAG = "drag";
export default SendDialog;

View File

@@ -3,7 +3,6 @@ import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
@@ -11,10 +10,10 @@ import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/mate
import theme from "./theme";
import api from "../app/Api";
import {topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
const publicBaseUrl = "https://ntfy.sh";
@@ -188,27 +187,4 @@ const LoginPage = (props) => {
);
};
const DialogFooter = (props) => {
return (
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingTop: '8px 24px',
paddingBottom: '8px 24px',
}}>
<DialogContentText sx={{
margin: '0px',
paddingTop: '8px',
}}>
{props.status}
</DialogContentText>
<DialogActions>
{props.children}
</DialogActions>
</Box>
);
};
export default SubscribeDialog;

View File

@@ -6,6 +6,7 @@ import notifier from "../app/Notifier";
import routes from "./routes";
import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller";
import pruner from "../app/Pruner";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -67,29 +68,13 @@ export const useAutoSubscribe = (subscriptions, selected) => {
};
/**
* Migrate the 'topics' item in localStorage to the subscriptionManager. This is only done once to migrate away
* from the old web UI.
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
*/
export const useLocalStorageMigration = () => {
const [hasRun, setHasRun] = useState(false);
export const useBackgroundProcesses = () => {
useEffect(() => {
if (hasRun) {
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);
poller.startWorker();
pruner.startWorker();
}, []);
}

View File

@@ -0,0 +1 @@
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M4.882 16.057c-.33-.114-.636-.42-.795-.797-.084-.202-.1-.292-.1-.578-.001-.302.011-.366.116-.6a1.44 1.44 0 01.652-.704l.205-.106h14.077l.205.106c.756.39 1.01 1.376.548 2.128a1.217 1.217 0 01-.588.515l-.201.091-6.985-.001c-5.641-.002-7.013-.012-7.134-.054zM4.858 10.595c-.33-.114-.635-.42-.794-.797-.085-.201-.1-.292-.101-.578 0-.302.012-.366.116-.6a1.44 1.44 0 01.653-.704l.205-.106h14.076l.205.106c.757.39 1.01 1.377.548 2.128a1.217 1.217 0 01-.587.515l-.202.092-6.984-.002c-5.642-.002-7.013-.012-7.135-.054z" fill="#0091ff"/></svg>

After

Width:  |  Height:  |  Size: 605 B