mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749e334396 | ||
|
|
78a681f277 | ||
|
|
3f96fad7ce | ||
|
|
83bb9951b0 | ||
|
|
4a5f34801a | ||
|
|
2cd7839da3 | ||
|
|
35ddcb27f0 | ||
|
|
328aca48ab | ||
|
|
4eba641ec3 | ||
|
|
f2d4af04e3 | ||
|
|
d44ee2bbf6 | ||
|
|
6f07944442 | ||
|
|
7716b1e81e | ||
|
|
8914809775 | ||
|
|
dcb5531038 | ||
|
|
0c666f96b1 | ||
|
|
d9c3c20350 | ||
|
|
73349cd423 | ||
|
|
b3667a916b | ||
|
|
6791c7395b | ||
|
|
aba7e86cbc | ||
|
|
12f973f61b | ||
|
|
f98743dd9b | ||
|
|
2c8b258ae7 | ||
|
|
00520a7a38 | ||
|
|
611894fd05 | ||
|
|
aabae53e5d | ||
|
|
85cf7bb687 | ||
|
|
44b9358c60 | ||
|
|
ee6188d100 | ||
|
|
2bdae49425 | ||
|
|
9814a9f792 | ||
|
|
5aedfd3898 | ||
|
|
7ff34364a3 | ||
|
|
3d0d70dc17 | ||
|
|
c5a1344e8a | ||
|
|
402b05a27b | ||
|
|
3e121f5d3c | ||
|
|
b6426f0417 | ||
|
|
e0b3068a5e | ||
|
|
8f2851e20a | ||
|
|
2eeb7d63a0 | ||
|
|
b20df55b88 | ||
|
|
187c19f3b2 | ||
|
|
d5eff0cd34 | ||
|
|
2e92be0f23 | ||
|
|
94b0e6f690 | ||
|
|
cc90a1af15 |
@@ -2,4 +2,6 @@ FROM alpine
|
||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
|
||||
COPY ntfy /usr/bin
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
@@ -26,28 +26,28 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.19.0/ntfy_1.19.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.19.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.19.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.19.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
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.19.0/ntfy_1.19.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.19.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.19.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.19.0_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.19.0/ntfy_1.19.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.19.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.19.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.19.0_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.19.0/ntfy_1.19.0_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.19.0/ntfy_1.19.0_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.19.0/ntfy_1.19.0_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.19.0/ntfy_1.19.0_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.19.0/ntfy_1.19.0_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.19.0/ntfy_1.19.0_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
|
||||
|
||||
@@ -18,6 +18,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
* 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:**
|
||||
|
||||
@@ -25,9 +26,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
||||
* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy))
|
||||
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony))
|
||||
* French (*incomplete*, thanks to [@Kusoneko](https://kusoneko.moe/))
|
||||
* 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))
|
||||
@@ -40,6 +42,26 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/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
|
||||
|
||||
|
||||
12
go.mod
12
go.mod
@@ -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-20220321153916-2c7772ba3064
|
||||
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
|
||||
)
|
||||
|
||||
@@ -39,12 +39,12 @@ 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-20220319134239-a9b59b0215f8 // 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-20220322021311-435b647f9ef2 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect
|
||||
google.golang.org/grpc v1.45.0 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
|
||||
15
go.sum
15
go.sum
@@ -67,6 +67,8 @@ github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tS
|
||||
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=
|
||||
@@ -248,6 +250,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
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=
|
||||
@@ -323,6 +327,9 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
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=
|
||||
@@ -412,6 +419,9 @@ golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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=
|
||||
@@ -526,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=
|
||||
@@ -610,6 +622,9 @@ google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2
|
||||
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ var (
|
||||
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) {
|
||||
@@ -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)
|
||||
@@ -395,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
|
||||
}
|
||||
@@ -539,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)
|
||||
@@ -575,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 {
|
||||
@@ -599,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)
|
||||
@@ -607,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
|
||||
}
|
||||
@@ -1096,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
|
||||
}
|
||||
@@ -1216,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()
|
||||
|
||||
@@ -938,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)
|
||||
}
|
||||
@@ -967,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)
|
||||
}
|
||||
@@ -987,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)
|
||||
}
|
||||
@@ -1024,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) {
|
||||
@@ -1036,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) {
|
||||
@@ -1068,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) {
|
||||
@@ -1144,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
61
util/peak.go
61
util/peak.go
@@ -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
61
util/peek.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
2243
web/package-lock.json
generated
2243
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
@@ -135,7 +135,15 @@ const SettingsIcons = (props) => {
|
||||
`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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, useBackgroundProcesses} 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
38
web/src/components/AttachmentIcon.js
Normal file
38
web/src/components/AttachmentIcon.js
Normal 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;
|
||||
29
web/src/components/DialogFooter.js
Normal file
29
web/src/components/DialogFooter.js
Normal 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;
|
||||
169
web/src/components/EmojiPicker.js
Normal file
169
web/src/components/EmojiPicker.js
Normal 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;
|
||||
111
web/src/components/Messaging.js
Normal file
111
web/src/components/Messaging.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
655
web/src/components/SendDialog.js
Normal file
655
web/src/components/SendDialog.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
1
web/src/img/priority-3.svg
Normal file
1
web/src/img/priority-3.svg
Normal 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 |
Reference in New Issue
Block a user