Compare commits
64 Commits
v2.9.0
...
pg-message
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8407871c | ||
|
|
6eff5553b5 | ||
|
|
7cac03c1ec | ||
|
|
130039f5c8 | ||
|
|
bec0d4807b | ||
|
|
5ee62033b5 | ||
|
|
3e02d7b0bb | ||
|
|
290ed1124e | ||
|
|
fc62682334 | ||
|
|
28404565d2 | ||
|
|
f8548e9d46 | ||
|
|
d90b290cd2 | ||
|
|
21c6776269 | ||
|
|
7fed392e0c | ||
|
|
913b59b5e3 | ||
|
|
4692ca7b7f | ||
|
|
af16542d02 | ||
|
|
5511812e30 | ||
|
|
547b09a7e5 | ||
|
|
b9c176ddba | ||
|
|
f971377cbb | ||
|
|
a04f2f9c9a | ||
|
|
763eafd5dd | ||
|
|
9247dac50d | ||
|
|
de65d07518 | ||
|
|
1966f80855 | ||
|
|
4b2e38320d | ||
|
|
83356f565e | ||
|
|
7fd5f0b29d | ||
|
|
03737dbf5c | ||
|
|
867cf28080 | ||
|
|
b2eb5b94bd | ||
|
|
df7d6baec5 | ||
|
|
a4f5c8dee7 | ||
|
|
4c0ec3f75b | ||
|
|
12bbe9a1ae | ||
|
|
0a589f6242 | ||
|
|
ab2dd6136e | ||
|
|
4d64515e45 | ||
|
|
411597ecc2 | ||
|
|
1a426da913 | ||
|
|
7936c38feb | ||
|
|
d0beaa900f | ||
|
|
f4bf8fd9bb | ||
|
|
d866cb2fd9 | ||
|
|
0ab6171962 | ||
|
|
b7c2898b9c | ||
|
|
d155ebb3a4 | ||
|
|
3d5bb92774 | ||
|
|
a2f1a45097 | ||
|
|
b43fff7c7e | ||
|
|
b186c7a324 | ||
|
|
e50b6f4075 | ||
|
|
e5342d5eca | ||
|
|
a25fc1daaa | ||
|
|
6e75108aa9 | ||
|
|
0735a5cb48 | ||
|
|
088aede833 | ||
|
|
2119954f67 | ||
|
|
220b012ae3 | ||
|
|
e1278a5e92 | ||
|
|
0ae62d36d2 | ||
|
|
70729edb2b | ||
|
|
de2f7d3e9b |
27
README.md
27
README.md
@@ -9,7 +9,6 @@
|
|||||||
[](https://discord.gg/cT7ECsZj9w)
|
[](https://discord.gg/cT7ECsZj9w)
|
||||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
||||||
[](https://discuss.ntfy.sh/c/ntfy)
|
|
||||||
[](https://ntfy.statuspage.io/)
|
[](https://ntfy.statuspage.io/)
|
||||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ works best for you:
|
|||||||
|
|
||||||
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
||||||
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
||||||
* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
|
|
||||||
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
||||||
|
|
||||||
## Announcements/beta testers
|
## Announcements/beta testers
|
||||||
@@ -71,7 +69,7 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
|
|||||||
## Sponsors
|
## Sponsors
|
||||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
||||||
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
||||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||||
|
|
||||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||||
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||||
@@ -168,6 +166,29 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
|||||||
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
|
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
|
||||||
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
|
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
|
||||||
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
|
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Emiliaaah"><img src="https://github.com/Emiliaaah.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/zark0s"><img src="https://github.com/zark0s.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/tomershvueli"><img src="https://github.com/tomershvueli.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/CataIana"><img src="https://github.com/CataIana.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/ajay-actuary"><img src="https://github.com/ajay-actuary.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/mursec"><img src="https://github.com/mursec.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/FrameXX"><img src="https://github.com/FrameXX.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/vovayartsev"><img src="https://github.com/vovayartsev.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/dwain-lab"><img src="https://github.com/dwain-lab.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/brookmg"><img src="https://github.com/brookmg.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/siebej"><img src="https://github.com/siebej.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/rxsantos"><img src="https://github.com/rxsantos.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/hermannx5"><img src="https://github.com/hermannx5.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/rwxd"><img src="https://github.com/rwxd.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Integral-Tech"><img src="https://github.com/Integral-Tech.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/TheTomik1"><img src="https://github.com/TheTomik1.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/dav23r"><img src="https://github.com/dav23r.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/stannynuytkens"><img src="https://github.com/stannynuytkens.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/danbartram"><img src="https://github.com/danbartram.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/arthurgleckler"><img src="https://github.com/arthurgleckler.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/tomroth04"><img src="https://github.com/tomroth04.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Circenn5130"><img src="https://github.com/Circenn5130.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/jceloria"><img src="https://github.com/jceloria.png" width="40px" /></a>
|
||||||
|
|
||||||
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
|
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
|
||||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||||
|
|||||||
@@ -607,6 +607,8 @@ This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](
|
|||||||
|
|
||||||
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
||||||
|
|
||||||
|
**Info:** Add a phone number to your traccar account not in device, as otherwise it will not try to send SMS.
|
||||||
|
|
||||||
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
||||||
```xml
|
```xml
|
||||||
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
||||||
|
|||||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.9.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.10.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.9.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.10.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.9.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.9.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.10.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.9.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.10.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.9.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.9.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.10.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.9.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.10.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.9.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.9.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.10.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.9.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.10.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.9.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -118,7 +118,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -126,7 +126,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -134,7 +134,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -144,28 +144,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_darwin_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_darwin_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_darwin_all.tar.gz > ntfy_2.9.0_darwin_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_darwin_all.tar.gz > ntfy_2.10.0_darwin_all.tar.gz
|
||||||
tar zxvf ntfy_2.9.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.10.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.9.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.10.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.9.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.10.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ brew install ntfy
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.9.0/ntfy_2.9.0_windows_amd64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_windows_amd64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
|
|
||||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
|
|||||||
@@ -144,9 +144,18 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
- [ntfy / Emacs Lisp](https://speechcode.com/blog/ntfy/) - speechcode.com - 3/2024
|
||||||
- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024
|
- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024
|
||||||
|
- [Nextcloud Talk (F-Droid version) notifications using ntfy (ntfy.sh)](https://www.youtube.com/watch?v=0a6PpfN5PD8) - youtube.com - 2/2024
|
||||||
|
- [ZFS and SMART Warnings via Ntfy](https://rair.dev/zfs-smart-ntfy/) - rair.dev - 2/2024
|
||||||
|
- [Automating Security Camera Notifications With Home Assistant and Ntfy](https://runtimeterror.dev/automating-camera-notifications-home-assistant-ntfy/) ⭐ - runtimeterror.dev - 2/2024
|
||||||
|
- [Ntfy: self-hosted notification service](https://medium.com/@williamdonze/ntfy-self-hosted-notification-service-0f3eada6e657) ⭐ - williamdonze.medium.com - 1/2024
|
||||||
|
- [Let’s Supercharge Snowflake Alerts with Cool ntfy Open-source Notifications!](https://sarathi-data-ml-cloud.medium.com/lets-supercharge-snowflake-alerts-with-cool-ntfy-open-source-notifications-296da442c331) - sarathi-data-ml-cloud.medium.com - 1/2024
|
||||||
|
- [Setting up NTFY with Ngnix-Proxy-Manager, authentication and Ansible notifications](https://random-it-blog.de/rocky-linux/setting-up-ntfy-with-ngnix-proxy-manager-authentication-and-ansible-notifications/) - random-it-blog.de - 12/2023
|
||||||
- [Introducing the Monitoring Ntfy.sh Integration Module: Real-time Notifications for Drupal Monitoring](https://cyberschorsch.dev/drupal/introducing-monitoring-ntfysh-integration-module-real-time-notifications-drupal-monitoring) - cyberschorsch.dev - 11/2023
|
- [Introducing the Monitoring Ntfy.sh Integration Module: Real-time Notifications for Drupal Monitoring](https://cyberschorsch.dev/drupal/introducing-monitoring-ntfysh-integration-module-real-time-notifications-drupal-monitoring) - cyberschorsch.dev - 11/2023
|
||||||
- [How to install Ntfy.sh on CasaOS using BigBearCasaOS](https://www.youtube.com/watch?v=wSWhtSNwTd8) - youtube.com - 10/2023
|
- [How to install Ntfy.sh on CasaOS using BigBearCasaOS](https://www.youtube.com/watch?v=wSWhtSNwTd8) - youtube.com - 10/2023
|
||||||
|
- [Podman Update Notifications via Ntfy](https://rair.dev/podman-update-notifications-ntfy/) - rair.dev - 9/2023
|
||||||
|
- [Easy Push Notifications With ntfy.sh](https://runtimeterror.dev/easy-push-notifications-with-ntfy/) ⭐ - runtimeterror.dev - 9/2023
|
||||||
- [Ntfy: Your Ultimate Push Notification Powerhouse!](https://kkamalesh117.medium.com/ntfy-your-ultimate-push-notification-powerhouse-1968c070f1d1) - kkamalesh117.medium.com - 9/2023
|
- [Ntfy: Your Ultimate Push Notification Powerhouse!](https://kkamalesh117.medium.com/ntfy-your-ultimate-push-notification-powerhouse-1968c070f1d1) - kkamalesh117.medium.com - 9/2023
|
||||||
- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023
|
- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023
|
||||||
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
|
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
|
||||||
|
|||||||
144
docs/publish.md
144
docs/publish.md
File diff suppressed because one or more lines are too long
@@ -2,13 +2,28 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
|
### ntfy server v2.10.0
|
||||||
|
Released Mar 27, 2024
|
||||||
|
|
||||||
|
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||||
|
title template that will be filled with values from a JSON body (e.g. `curl -gd '{"alert":"Disk space low"}' "ntfy.sh/mytopic?tpl=1&m={{.alert}}"`).
|
||||||
|
This is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||||
|
|
||||||
### ntfy server v2.9.0
|
### ntfy server v2.9.0
|
||||||
Released Mar 7, 2024
|
Released Mar 7, 2024
|
||||||
|
|
||||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer message delays** in scheduled delivery messages. The web app also now supports pasting images from the clipboard. Other than that, only a few bug fixes and documentation updates, and a teeny tiny breaking change 😬.
|
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||||
|
message delays** in scheduled delivery messages. The web app also now supports pasting images from the clipboard. Other
|
||||||
|
than that, only a few bug fixes and documentation updates, and a teeny tiny breaking change 😬.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
⚠️ **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used. Normally I'd never remove a feature, but this is a security issue, and likely affects almost nobody.
|
⚠️ **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects
|
||||||
|
installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.
|
||||||
|
Normally I'd never remove a feature, but this is a security issue, and likely affects almost nobody.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
@@ -30,7 +45,9 @@ A small release after a long pause (lots of day job work). This release adds for
|
|||||||
## ntfy iOS app v1.3
|
## ntfy iOS app v1.3
|
||||||
Released Nov 26, 2023
|
Released Nov 26, 2023
|
||||||
|
|
||||||
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs for a long time, and I hope that they are finally fixed.
|
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well
|
||||||
|
as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs
|
||||||
|
for a long time, and I hope that they are finally fixed.
|
||||||
|
|
||||||
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
|
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
|
||||||
|
|
||||||
@@ -41,7 +58,10 @@ Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and
|
|||||||
## ntfy server v2.8.0
|
## ntfy server v2.8.0
|
||||||
Released November 19, 2023
|
Released November 19, 2023
|
||||||
|
|
||||||
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes
|
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes
|
||||||
|
for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the
|
||||||
|
`Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally),
|
||||||
|
web app crash fixes
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
60
docs/static/js/extra.js
vendored
60
docs/static/js/extra.js
vendored
@@ -1,51 +1,53 @@
|
|||||||
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
||||||
|
|
||||||
const savedCodeTab = localStorage.getItem('savedTab')
|
const savedCodeTab = localStorage.getItem("savedTab");
|
||||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
const codeTabs = document.querySelectorAll(".tabbed-set > input");
|
||||||
for (const tab of codeTabs) {
|
for (const tab of codeTabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
const pos = current.getBoundingClientRect().top
|
const pos = current.getBoundingClientRect().top;
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML;
|
||||||
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
|
const labels = document.querySelectorAll(".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label");
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
if (label.innerHTML === labelContent) {
|
if (label.innerHTML === labelContent) {
|
||||||
document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true
|
document.querySelector(`input[id=${label.getAttribute("for")}]`).checked = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve scroll position
|
// Preserve scroll position
|
||||||
const delta = (current.getBoundingClientRect().top) - pos
|
const delta = (current.getBoundingClientRect().top) - pos;
|
||||||
window.scrollBy(0, delta)
|
window.scrollBy(0, delta);
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
localStorage.setItem('savedTab', labelContent)
|
localStorage.setItem("savedTab", labelContent);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Select saved tab
|
// Select saved tab
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML;
|
||||||
if (savedCodeTab === labelContent) {
|
if (savedCodeTab === labelContent) {
|
||||||
tab.checked = true
|
tab.checked = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightbox for screenshot
|
// Lightbox for screenshot
|
||||||
|
|
||||||
const lightbox = document.createElement('div');
|
const lightbox = document.createElement("div");
|
||||||
lightbox.classList.add('lightbox');
|
lightbox.classList.add("lightbox");
|
||||||
document.body.appendChild(lightbox);
|
document.body.appendChild(lightbox);
|
||||||
|
|
||||||
const showScreenshotOverlay = (e, el, group, index) => {
|
const showScreenshotOverlay = (e, el, group, index) => {
|
||||||
lightbox.classList.add('show');
|
lightbox.classList.add("show");
|
||||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
document.addEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
return showScreenshot(e, group, index);
|
return showScreenshot(e, group, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showScreenshot = (e, group, index) => {
|
const showScreenshot = (e, group, index) => {
|
||||||
const actualIndex = resolveScreenshotIndex(group, index);
|
const actualIndex = resolveScreenshotIndex(group, index);
|
||||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
|
lightbox.innerHTML = "<div class=\"close-lightbox\"></div>" + screenshots[group][actualIndex].innerHTML;
|
||||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
|
lightbox.querySelector("img").onclick = (e) => {
|
||||||
|
return showScreenshot(e, group, actualIndex + 1);
|
||||||
|
};
|
||||||
currentScreenshotGroup = group;
|
currentScreenshotGroup = group;
|
||||||
currentScreenshotIndex = actualIndex;
|
currentScreenshotIndex = actualIndex;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -53,11 +55,11 @@ const showScreenshot = (e, group, index) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshot = (e) => {
|
const nextScreenshot = (e) => {
|
||||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1);
|
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousScreenshot = (e) => {
|
const previousScreenshot = (e) => {
|
||||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1);
|
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveScreenshotIndex = (group, index) => {
|
const resolveScreenshotIndex = (group, index) => {
|
||||||
@@ -70,8 +72,8 @@ const resolveScreenshotIndex = (group, index) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hideScreenshotOverlay = (e) => {
|
const hideScreenshotOverlay = (e) => {
|
||||||
lightbox.classList.remove('show');
|
lightbox.classList.remove("show");
|
||||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshotKeyboardListener = (e) => {
|
const nextScreenshotKeyboardListener = (e) => {
|
||||||
@@ -85,14 +87,16 @@ const nextScreenshotKeyboardListener = (e) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentScreenshotGroup = '';
|
let currentScreenshotGroup = "";
|
||||||
let currentScreenshotIndex = 0;
|
let currentScreenshotIndex = 0;
|
||||||
let screenshots = {};
|
let screenshots = {};
|
||||||
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
|
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
|
||||||
const group = sg.id;
|
const group = sg.id;
|
||||||
screenshots[group] = [...sg.querySelectorAll('a')];
|
screenshots[group] = [...sg.querySelectorAll("a")];
|
||||||
screenshots[group].forEach((el, index) => {
|
screenshots[group].forEach((el, index) => {
|
||||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); };
|
el.onclick = (e) => {
|
||||||
|
return showScreenshotOverlay(e, el, group, index);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
69
go.mod
69
go.mod
@@ -6,22 +6,22 @@ toolchain go1.21.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.15.0 // indirect
|
cloud.google.com/go/firestore v1.15.0 // indirect
|
||||||
cloud.google.com/go/storage v1.39.0 // indirect
|
cloud.google.com/go/storage v1.40.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||||
github.com/emersion/go-smtp v0.18.0
|
github.com/emersion/go-smtp v0.18.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3
|
github.com/gabriel-vasile/mimetype v1.4.3
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
github.com/olebedev/when v1.0.0
|
github.com/olebedev/when v1.0.0
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.9.0
|
||||||
github.com/urfave/cli/v2 v2.27.1
|
github.com/urfave/cli/v2 v2.27.1
|
||||||
golang.org/x/crypto v0.21.0
|
golang.org/x/crypto v0.22.0
|
||||||
golang.org/x/oauth2 v0.18.0 // indirect
|
golang.org/x/oauth2 v0.19.0 // indirect
|
||||||
golang.org/x/sync v0.6.0
|
golang.org/x/sync v0.7.0
|
||||||
golang.org/x/term v0.18.0
|
golang.org/x/term v0.19.0
|
||||||
golang.org/x/time v0.5.0
|
golang.org/x/time v0.5.0
|
||||||
google.golang.org/api v0.168.0
|
google.golang.org/api v0.176.1
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,24 +30,26 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
|
|||||||
require github.com/pkg/errors v0.9.1 // indirect
|
require github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.13.0
|
firebase.google.com/go/v4 v4.14.0
|
||||||
github.com/SherClockHolmes/webpush-go v1.3.0
|
github.com/SherClockHolmes/webpush-go v1.3.0
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26
|
github.com/microcosm-cc/bluemonday v1.0.26
|
||||||
github.com/prometheus/client_golang v1.19.0
|
github.com/prometheus/client_golang v1.19.0
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.112.1 // indirect
|
cloud.google.com/go v0.112.2 // indirect
|
||||||
cloud.google.com/go/compute v1.25.0 // indirect
|
cloud.google.com/go/auth v0.3.0 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||||
cloud.google.com/go/iam v1.1.6 // indirect
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
cloud.google.com/go/longrunning v0.5.5 // indirect
|
cloud.google.com/go/iam v1.1.7 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.5.6 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
@@ -60,31 +62,30 @@ require (
|
|||||||
github.com/google/s2a-go v0.1.7 // indirect
|
github.com/google/s2a-go v0.1.7 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.12.2 // indirect
|
github.com/googleapis/gax-go/v2 v2.12.3 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.0 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.50.0 // indirect
|
github.com/prometheus/common v0.53.0 // indirect
|
||||||
github.com/prometheus/procfs v0.13.0 // indirect
|
github.com/prometheus/procfs v0.14.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/otel v1.25.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.25.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/otel/trace v1.25.0 // indirect
|
||||||
golang.org/x/net v0.22.0 // indirect
|
golang.org/x/net v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.18.0 // indirect
|
golang.org/x/sys v0.19.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
google.golang.org/appengine v1.6.8 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.5 // indirect
|
google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect
|
google.golang.org/grpc v1.63.2 // indirect
|
||||||
google.golang.org/grpc v1.62.1 // indirect
|
|
||||||
google.golang.org/protobuf v1.33.0 // indirect
|
google.golang.org/protobuf v1.33.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
139
go.sum
139
go.sum
@@ -1,20 +1,22 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
|
cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw=
|
||||||
cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
|
cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms=
|
||||||
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
|
cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs=
|
||||||
cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE=
|
cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
|
cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
|
||||||
cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
|
cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
|
||||||
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
|
cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM=
|
||||||
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
|
cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA=
|
||||||
cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg=
|
cloud.google.com/go/longrunning v0.5.6 h1:xAe8+0YaWoCKr9t1+aWe+OeQgN/iJK1fEgZSXmjuEaE=
|
||||||
cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
|
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
|
||||||
cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA=
|
cloud.google.com/go/storage v1.40.0 h1:VEpDQV5CJxFmJ6ueWNsKxcr1QAYOXEgxDa+sBbJahPw=
|
||||||
cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk=
|
cloud.google.com/go/storage v1.40.0/go.mod h1:Rrj7/hKlG87BLqDJYtwR0fbPld8uJPbQ2ucUMY7Ir0g=
|
||||||
firebase.google.com/go/v4 v4.13.0 h1:meFz9nvDNh/FDyrEykoAzSfComcQbmnQSjoHrePRqeI=
|
firebase.google.com/go/v4 v4.14.0 h1:Tc9jWzMUApUFUA5UUx/HcBeZ+LPjlhG2vNRfWJrcMwU=
|
||||||
firebase.google.com/go/v4 v4.13.0/go.mod h1:e1/gaR6EnbQfsmTnAMx1hnz+ninJIrrr/RAh59Tpfn8=
|
firebase.google.com/go/v4 v4.14.0/go.mod h1:pLATyL6xH2o9AMe7rqHdmmOUE/Ph7wcwepIs+uiEKPg=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
@@ -29,12 +31,12 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3
|
|||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -77,7 +79,6 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
|||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
@@ -98,8 +99,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
|
github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
|
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
@@ -108,6 +109,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||||
@@ -121,53 +124,54 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
|||||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
|
github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
|
||||||
github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
|
github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
|
||||||
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
|
github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s=
|
||||||
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
|
github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
|
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
|
||||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0 h1:zvpPXY7RfYAGSdYQLjp6zxdJNSYD/+FFoCTQN9IPxBs=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0/go.mod h1:BMn8NB1vsxTljvuorms2hyOs8IBuuBEq0pl7ltOfy30=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 h1:cEPbyTSEHlQR89XVlyo78gqluF8Y3oMeBkXGWzQsfXY=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0/go.mod h1:DKdbWcT4GH1D0Y3Sqt/PFXt2naRKDWtU+eE6oLdFNA8=
|
||||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k=
|
||||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
|
||||||
go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
|
go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
|
||||||
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
|
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
@@ -186,18 +190,18 @@ golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qx
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
|
||||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -209,14 +213,14 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -240,30 +244,28 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
|||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY=
|
google.golang.org/api v0.176.1 h1:DJSXnV6An+NhJ1J+GWtoF2nHEuqB1VNoTfnIbjNvwD4=
|
||||||
google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
|
google.golang.org/api v0.176.1/go.mod h1:j2MaSDYcvYV1lkZ1+SMW4IeF90SrEyFA+tluDYWRrFg=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/appengine/v2 v2.0.5 h1:4C+F3Cd3L2nWEfSmFEZDPjQvDwL8T0YCeZBysZifP3k=
|
|
||||||
google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ=
|
google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be h1:g4aX8SUFA8V5F4LrSY5EclyGYw1OZN4HS1jTyjB9ZDc=
|
||||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
|
google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be/go.mod h1:FeSdT5fk+lkxatqJP38MsUicGqHax5cLtmy/6TAuxO4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be h1:Zz7rLWqp0ApfsR/l7+zSHhY3PMiH2xqgxlfYfAfNpoU=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be/go.mod h1:dvdCTIoAGbkWbcIKBniID56/7XHTt6WfxXNMxuziJ+w=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
|
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
|
||||||
google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
|
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -274,7 +276,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ var (
|
|||||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
||||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil}
|
||||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
||||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||||
@@ -113,10 +113,15 @@ var (
|
|||||||
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil}
|
||||||
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
||||||
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
||||||
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
||||||
|
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
|
|||||||
@@ -4,332 +4,81 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
|
||||||
"heckel.io/ntfy/v2/log"
|
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
type MessageCache interface {
|
||||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
AddMessage(m *message) error
|
||||||
errMessageNotFound = errors.New("message not found")
|
AddMessages(ms []*message) error
|
||||||
errNoRows = errors.New("no rows found")
|
Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error)
|
||||||
)
|
MessagesDue() ([]*message, error)
|
||||||
|
MessagesExpired() ([]string, error)
|
||||||
|
Message(id string) (*message, error)
|
||||||
|
MarkPublished(m *message) error
|
||||||
|
MessageCounts() (map[string]int, error)
|
||||||
|
Topics() (map[string]*topic, error)
|
||||||
|
DeleteMessages(ids ...string) error
|
||||||
|
ExpireMessages(topics ...string) error
|
||||||
|
AttachmentsExpired() ([]string, error)
|
||||||
|
MarkAttachmentsDeleted(ids ...string) error
|
||||||
|
AttachmentBytesUsedBySender(sender string) (int64, error)
|
||||||
|
AttachmentBytesUsedByUser(userID string) (int64, error)
|
||||||
|
UpdateStats(messages int64) error
|
||||||
|
Stats() (messages int64, err error)
|
||||||
|
DB() *sql.DB
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
// Messages cache
|
type commonMessageCache struct {
|
||||||
const (
|
|
||||||
createMessagesTableQuery = `
|
|
||||||
BEGIN;
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mid TEXT NOT NULL,
|
|
||||||
time INT NOT NULL,
|
|
||||||
expires INT NOT NULL,
|
|
||||||
topic TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
priority INT NOT NULL,
|
|
||||||
tags TEXT NOT NULL,
|
|
||||||
click TEXT NOT NULL,
|
|
||||||
icon TEXT NOT NULL,
|
|
||||||
actions TEXT NOT NULL,
|
|
||||||
attachment_name TEXT NOT NULL,
|
|
||||||
attachment_type TEXT NOT NULL,
|
|
||||||
attachment_size INT NOT NULL,
|
|
||||||
attachment_expires INT NOT NULL,
|
|
||||||
attachment_url TEXT NOT NULL,
|
|
||||||
attachment_deleted INT NOT NULL,
|
|
||||||
sender TEXT NOT NULL,
|
|
||||||
user TEXT NOT NULL,
|
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
encoding TEXT NOT NULL,
|
|
||||||
published INT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
|
||||||
CREATE TABLE IF NOT EXISTS stats (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value INT
|
|
||||||
);
|
|
||||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
|
||||||
COMMIT;
|
|
||||||
`
|
|
||||||
insertMessageQuery = `
|
|
||||||
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`
|
|
||||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
|
||||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
|
||||||
selectMessagesByIDQuery = `
|
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
|
||||||
FROM messages
|
|
||||||
WHERE mid = ?
|
|
||||||
`
|
|
||||||
selectMessagesSinceTimeQuery = `
|
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
|
||||||
FROM messages
|
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
|
||||||
ORDER BY time, id
|
|
||||||
`
|
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
|
||||||
FROM messages
|
|
||||||
WHERE topic = ? AND time >= ?
|
|
||||||
ORDER BY time, id
|
|
||||||
`
|
|
||||||
selectMessagesSinceIDQuery = `
|
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
|
||||||
FROM messages
|
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
|
||||||
ORDER BY time, id
|
|
||||||
`
|
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
|
||||||
FROM messages
|
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
|
||||||
ORDER BY time, id
|
|
||||||
`
|
|
||||||
selectMessagesDueQuery = `
|
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
|
||||||
FROM messages
|
|
||||||
WHERE time <= ? AND published = 0
|
|
||||||
ORDER BY time, id
|
|
||||||
`
|
|
||||||
selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
|
|
||||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
|
||||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
|
||||||
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
|
|
||||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
|
||||||
|
|
||||||
updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
|
|
||||||
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
|
|
||||||
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
|
|
||||||
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
|
||||||
|
|
||||||
selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
|
|
||||||
updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schema management queries
|
|
||||||
const (
|
|
||||||
currentSchemaVersion = 12
|
|
||||||
createSchemaVersionTableQuery = `
|
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
|
||||||
id INT PRIMARY KEY,
|
|
||||||
version INT NOT NULL
|
|
||||||
);
|
|
||||||
`
|
|
||||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
|
||||||
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
|
||||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
|
||||||
|
|
||||||
// 0 -> 1
|
|
||||||
migrate0To1AlterMessagesTableQuery = `
|
|
||||||
BEGIN;
|
|
||||||
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
|
|
||||||
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
|
||||||
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
|
|
||||||
COMMIT;
|
|
||||||
`
|
|
||||||
|
|
||||||
// 1 -> 2
|
|
||||||
migrate1To2AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
|
||||||
`
|
|
||||||
|
|
||||||
// 2 -> 3
|
|
||||||
migrate2To3AlterMessagesTableQuery = `
|
|
||||||
BEGIN;
|
|
||||||
ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
|
|
||||||
COMMIT;
|
|
||||||
`
|
|
||||||
// 3 -> 4
|
|
||||||
migrate3To4AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
|
|
||||||
`
|
|
||||||
|
|
||||||
// 4 -> 5
|
|
||||||
migrate4To5AlterMessagesTableQuery = `
|
|
||||||
BEGIN;
|
|
||||||
CREATE TABLE IF NOT EXISTS messages_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mid TEXT NOT NULL,
|
|
||||||
time INT NOT NULL,
|
|
||||||
topic TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
priority INT NOT NULL,
|
|
||||||
tags TEXT NOT NULL,
|
|
||||||
click TEXT NOT NULL,
|
|
||||||
attachment_name TEXT NOT NULL,
|
|
||||||
attachment_type TEXT NOT NULL,
|
|
||||||
attachment_size INT NOT NULL,
|
|
||||||
attachment_expires INT NOT NULL,
|
|
||||||
attachment_url TEXT NOT NULL,
|
|
||||||
attachment_owner TEXT NOT NULL,
|
|
||||||
encoding TEXT NOT NULL,
|
|
||||||
published INT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);
|
|
||||||
INSERT
|
|
||||||
INTO messages_new (
|
|
||||||
mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
|
||||||
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
|
||||||
SELECT
|
|
||||||
id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
|
||||||
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published
|
|
||||||
FROM messages;
|
|
||||||
DROP TABLE messages;
|
|
||||||
ALTER TABLE messages_new RENAME TO messages;
|
|
||||||
COMMIT;
|
|
||||||
`
|
|
||||||
|
|
||||||
// 5 -> 6
|
|
||||||
migrate5To6AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
|
||||||
`
|
|
||||||
|
|
||||||
// 6 -> 7
|
|
||||||
migrate6To7AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
|
|
||||||
`
|
|
||||||
|
|
||||||
// 7 -> 8
|
|
||||||
migrate7To8AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
|
|
||||||
`
|
|
||||||
|
|
||||||
// 8 -> 9
|
|
||||||
migrate8To9AlterMessagesTableQuery = `
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
|
||||||
`
|
|
||||||
|
|
||||||
// 9 -> 10
|
|
||||||
migrate9To10AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT('');
|
|
||||||
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
|
||||||
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
|
||||||
`
|
|
||||||
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
|
||||||
|
|
||||||
// 10 -> 11
|
|
||||||
migrate10To11AlterMessagesTableQuery = `
|
|
||||||
CREATE TABLE IF NOT EXISTS stats (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value INT
|
|
||||||
);
|
|
||||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
|
||||||
`
|
|
||||||
|
|
||||||
// 11 -> 12
|
|
||||||
migrate11To12AlterMessagesTableQuery = `
|
|
||||||
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
|
||||||
0: migrateFrom0,
|
|
||||||
1: migrateFrom1,
|
|
||||||
2: migrateFrom2,
|
|
||||||
3: migrateFrom3,
|
|
||||||
4: migrateFrom4,
|
|
||||||
5: migrateFrom5,
|
|
||||||
6: migrateFrom6,
|
|
||||||
7: migrateFrom7,
|
|
||||||
8: migrateFrom8,
|
|
||||||
9: migrateFrom9,
|
|
||||||
10: migrateFrom10,
|
|
||||||
11: migrateFrom11,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type messageCache struct {
|
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
queue *util.BatchingQueue[*message]
|
queue *util.BatchingQueue[*message]
|
||||||
nop bool
|
queries *messageCacheQueries
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSqliteCache creates a SQLite file-backed cache
|
var _ MessageCache = (*commonMessageCache)(nil)
|
||||||
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
|
||||||
db, err := sql.Open("sqlite3", filename)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var queue *util.BatchingQueue[*message]
|
|
||||||
if batchSize > 0 || batchTimeout > 0 {
|
|
||||||
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
|
|
||||||
}
|
|
||||||
cache := &messageCache{
|
|
||||||
db: db,
|
|
||||||
queue: queue,
|
|
||||||
nop: nop,
|
|
||||||
}
|
|
||||||
go cache.processMessageBatches()
|
|
||||||
return cache, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMemCache creates an in-memory cache
|
type messageCacheQueries struct {
|
||||||
func newMemCache() (*messageCache, error) {
|
insertMessage string
|
||||||
return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, false)
|
deleteMessage string
|
||||||
}
|
updateMessagesForTopicExpiry string
|
||||||
|
selectRowIDFromMessageID string // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
|
selectMessagesByID string
|
||||||
|
selectMessagesSinceTime string
|
||||||
|
selectMessagesSinceTimeIncludeScheduled string
|
||||||
|
selectMessagesSinceID string
|
||||||
|
selectMessagesSinceIDIncludeScheduled string
|
||||||
|
selectMessagesDue string
|
||||||
|
selectMessagesExpired string
|
||||||
|
updateMessagePublished string
|
||||||
|
selectMessageCountPerTopic string
|
||||||
|
selectTopics string
|
||||||
|
|
||||||
// newNopCache creates an in-memory cache that discards all messages;
|
updateAttachmentDeleted string
|
||||||
// it is always empty and can be used if caching is entirely disabled
|
selectAttachmentsExpired string
|
||||||
func newNopCache() (*messageCache, error) {
|
selectAttachmentsSizeBySender string
|
||||||
return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, true)
|
selectAttachmentsSizeByUserID string
|
||||||
}
|
|
||||||
|
|
||||||
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
selectStats string
|
||||||
// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory
|
updateStats string
|
||||||
// sql database, so if the stdlib's sql engine happens to open another connection and
|
|
||||||
// you've only specified ":memory:", that connection will see a brand new database.
|
|
||||||
// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared").
|
|
||||||
// Every connection to this string will point to the same in-memory database."
|
|
||||||
func createMemoryFilename() string {
|
|
||||||
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously.
|
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously.
|
||||||
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
|
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
|
||||||
func (c *messageCache) AddMessage(m *message) error {
|
func (c *commonMessageCache) AddMessage(m *message) error {
|
||||||
if c.queue != nil {
|
if c.queue != nil {
|
||||||
c.queue.Enqueue(m)
|
c.queue.Enqueue(m)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.addMessages([]*message{m})
|
return c.AddMessages([]*message{m})
|
||||||
}
|
}
|
||||||
|
|
||||||
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
// AddMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
||||||
// SQLite's busy_timeout is exceeded before erroring out.
|
// SQLite's busy_timeout is exceeded before erroring out.
|
||||||
func (c *messageCache) addMessages(ms []*message) error {
|
func (c *commonMessageCache) AddMessages(ms []*message) error {
|
||||||
if c.nop {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(ms) == 0 {
|
if len(ms) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -339,7 +88,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
stmt, err := tx.Prepare(insertMessageQuery)
|
stmt, err := tx.Prepare(c.queries.insertMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -351,7 +100,8 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
published := m.Time <= time.Now().Unix()
|
published := m.Time <= time.Now().Unix()
|
||||||
tags := strings.Join(m.Tags, ",")
|
tags := strings.Join(m.Tags, ",")
|
||||||
var attachmentName, attachmentType, attachmentURL string
|
var attachmentName, attachmentType, attachmentURL string
|
||||||
var attachmentSize, attachmentExpires, attachmentDeleted int64
|
var attachmentSize, attachmentExpires int64
|
||||||
|
var attachmentDeleted bool
|
||||||
if m.Attachment != nil {
|
if m.Attachment != nil {
|
||||||
attachmentName = m.Attachment.Name
|
attachmentName = m.Attachment.Name
|
||||||
attachmentType = m.Attachment.Type
|
attachmentType = m.Attachment.Type
|
||||||
@@ -388,7 +138,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
attachmentSize,
|
attachmentSize,
|
||||||
attachmentExpires,
|
attachmentExpires,
|
||||||
attachmentURL,
|
attachmentURL,
|
||||||
attachmentDeleted, // Always zero
|
attachmentDeleted, // Always false
|
||||||
sender,
|
sender,
|
||||||
m.User,
|
m.User,
|
||||||
m.ContentType,
|
m.ContentType,
|
||||||
@@ -407,7 +157,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *commonMessageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
return make([]*message, 0), nil
|
return make([]*message, 0), nil
|
||||||
} else if since.IsID() {
|
} else if since.IsID() {
|
||||||
@@ -416,13 +166,13 @@ func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool)
|
|||||||
return c.messagesSinceTime(topic, since, scheduled)
|
return c.messagesSinceTime(topic, since, scheduled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *commonMessageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if scheduled {
|
if scheduled {
|
||||||
rows, err = c.db.Query(selectMessagesSinceTimeIncludeScheduledQuery, topic, since.Time().Unix())
|
rows, err = c.db.Query(c.queries.selectMessagesSinceTimeIncludeScheduled, topic, since.Time().Unix())
|
||||||
} else {
|
} else {
|
||||||
rows, err = c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
|
rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -430,8 +180,8 @@ func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, schedu
|
|||||||
return readMessages(rows)
|
return readMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *commonMessageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID())
|
idrows, err := c.db.Query(c.queries.selectRowIDFromMessageID, since.ID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -446,9 +196,9 @@ func (c *messageCache) messagesSinceID(topic string, since sinceMarker, schedule
|
|||||||
idrows.Close()
|
idrows.Close()
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
if scheduled {
|
if scheduled {
|
||||||
rows, err = c.db.Query(selectMessagesSinceIDIncludeScheduledQuery, topic, rowID)
|
rows, err = c.db.Query(c.queries.selectMessagesSinceIDIncludeScheduled, topic, rowID)
|
||||||
} else {
|
} else {
|
||||||
rows, err = c.db.Query(selectMessagesSinceIDQuery, topic, rowID)
|
rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, rowID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -456,8 +206,8 @@ func (c *messageCache) messagesSinceID(topic string, since sinceMarker, schedule
|
|||||||
return readMessages(rows)
|
return readMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MessagesDue() ([]*message, error) {
|
func (c *commonMessageCache) MessagesDue() ([]*message, error) {
|
||||||
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
|
rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -465,8 +215,8 @@ func (c *messageCache) MessagesDue() ([]*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MessagesExpired returns a list of IDs for messages that have expires (should be deleted)
|
// MessagesExpired returns a list of IDs for messages that have expires (should be deleted)
|
||||||
func (c *messageCache) MessagesExpired() ([]string, error) {
|
func (c *commonMessageCache) MessagesExpired() ([]string, error) {
|
||||||
rows, err := c.db.Query(selectMessagesExpiredQuery, time.Now().Unix())
|
rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -485,25 +235,24 @@ func (c *messageCache) MessagesExpired() ([]string, error) {
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Message(id string) (*message, error) {
|
func (c *commonMessageCache) Message(id string) (*message, error) {
|
||||||
rows, err := c.db.Query(selectMessagesByIDQuery, id)
|
rows, err := c.db.Query(c.queries.selectMessagesByID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
} else if !rows.Next() {
|
||||||
if !rows.Next() {
|
|
||||||
return nil, errMessageNotFound
|
return nil, errMessageNotFound
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
return readMessage(rows)
|
return readMessage(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkPublished(m *message) error {
|
func (c *commonMessageCache) MarkPublished(m *message) error {
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(c.queries.updateMessagePublished, m.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MessageCounts() (map[string]int, error) {
|
func (c *commonMessageCache) MessageCounts() (map[string]int, error) {
|
||||||
rows, err := c.db.Query(selectMessageCountPerTopicQuery)
|
rows, err := c.db.Query(c.queries.selectMessageCountPerTopic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -522,8 +271,8 @@ func (c *messageCache) MessageCounts() (map[string]int, error) {
|
|||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Topics() (map[string]*topic, error) {
|
func (c *commonMessageCache) Topics() (map[string]*topic, error) {
|
||||||
rows, err := c.db.Query(selectTopicsQuery)
|
rows, err := c.db.Query(c.queries.selectTopics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -542,36 +291,36 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
|||||||
return topics, nil
|
return topics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) DeleteMessages(ids ...string) error {
|
func (c *commonMessageCache) DeleteMessages(ids ...string) error {
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if _, err := tx.Exec(deleteMessageQuery, id); err != nil {
|
if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
func (c *commonMessageCache) ExpireMessages(topics ...string) error {
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil {
|
if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
func (c *commonMessageCache) AttachmentsExpired() ([]string, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -590,37 +339,37 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
func (c *commonMessageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if _, err := tx.Exec(updateAttachmentDeleted, id); err != nil {
|
if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error) {
|
func (c *commonMessageCache) AttachmentBytesUsedBySender(sender string) (int64, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsSizeBySenderQuery, sender, time.Now().Unix())
|
rows, err := c.db.Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return c.readAttachmentBytesUsed(rows)
|
return c.readAttachmentBytesUsed(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) {
|
func (c *commonMessageCache) AttachmentBytesUsedByUser(userID string) (int64, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix())
|
rows, err := c.db.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return c.readAttachmentBytesUsed(rows)
|
return c.readAttachmentBytesUsed(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
|
func (c *commonMessageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var size int64
|
var size int64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
@@ -634,17 +383,45 @@ func (c *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
|
|||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) processMessageBatches() {
|
func (c *commonMessageCache) processMessageBatches() {
|
||||||
if c.queue == nil {
|
if c.queue == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for messages := range c.queue.Dequeue() {
|
for messages := range c.queue.Dequeue() {
|
||||||
if err := c.addMessages(messages); err != nil {
|
if err := c.AddMessages(messages); err != nil {
|
||||||
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
|
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *commonMessageCache) UpdateStats(messages int64) error {
|
||||||
|
_, err := c.db.Exec(c.queries.updateStats, messages)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commonMessageCache) Stats() (messages int64, err error) {
|
||||||
|
rows, err := c.db.Query(c.queries.selectStats)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return 0, errNoRows
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&messages); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commonMessageCache) DB() *sql.DB {
|
||||||
|
return c.db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commonMessageCache) Close() error {
|
||||||
|
return c.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func readMessages(rows *sql.Rows) ([]*message, error) {
|
func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
messages := make([]*message, 0)
|
messages := make([]*message, 0)
|
||||||
@@ -734,239 +511,3 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
Encoding: encoding,
|
Encoding: encoding,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) UpdateStats(messages int64) error {
|
|
||||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *messageCache) Stats() (messages int64, err error) {
|
|
||||||
rows, err := c.db.Query(selectStatsQuery)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
if !rows.Next() {
|
|
||||||
return 0, errNoRows
|
|
||||||
}
|
|
||||||
if err := rows.Scan(&messages); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return messages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *messageCache) Close() error {
|
|
||||||
return c.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
|
||||||
// Run startup queries
|
|
||||||
if startupQueries != "" {
|
|
||||||
if _, err := db.Exec(startupQueries); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If 'messages' table does not exist, this must be a new database
|
|
||||||
rowsMC, err := db.Query(selectMessagesCountQuery)
|
|
||||||
if err != nil {
|
|
||||||
return setupNewCacheDB(db)
|
|
||||||
}
|
|
||||||
rowsMC.Close()
|
|
||||||
|
|
||||||
// If 'messages' table exists, check 'schemaVersion' table
|
|
||||||
schemaVersion := 0
|
|
||||||
rowsSV, err := db.Query(selectSchemaVersionQuery)
|
|
||||||
if err == nil {
|
|
||||||
defer rowsSV.Close()
|
|
||||||
if !rowsSV.Next() {
|
|
||||||
return errors.New("cannot determine schema version: cache file may be corrupt")
|
|
||||||
}
|
|
||||||
if err := rowsSV.Scan(&schemaVersion); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rowsSV.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do migrations
|
|
||||||
if schemaVersion == currentSchemaVersion {
|
|
||||||
return nil
|
|
||||||
} else if schemaVersion > currentSchemaVersion {
|
|
||||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion)
|
|
||||||
}
|
|
||||||
for i := schemaVersion; i < currentSchemaVersion; i++ {
|
|
||||||
fn, ok := migrations[i]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
|
||||||
} else if err := fn(db, cacheDuration); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupNewCacheDB(db *sql.DB) error {
|
|
||||||
if _, err := db.Exec(createMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
|
||||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
|
||||||
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
|
||||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
|
||||||
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
|
||||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
|
||||||
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
|
||||||
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
|
||||||
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
|
||||||
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.Exec(migrate9To10AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom10(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(updateSchemaVersion, 11); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateFrom11(db *sql.DB, _ time.Duration) error {
|
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.Exec(migrate11To12AlterMessagesTableQuery); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(updateSchemaVersion, 12); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|||||||
179
server/message_cache_pg.go
Normal file
179
server/message_cache_pg.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "github.com/lib/pq" // PostgreSQL driver
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Messages cache
|
||||||
|
const (
|
||||||
|
pgCreateMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
|
time INT NOT NULL,
|
||||||
|
expires INT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
|
actions TEXT NOT NULL,
|
||||||
|
attachment_name TEXT NOT NULL,
|
||||||
|
attachment_type TEXT NOT NULL,
|
||||||
|
attachment_size INT NOT NULL,
|
||||||
|
attachment_expires INT NOT NULL,
|
||||||
|
attachment_url TEXT NOT NULL,
|
||||||
|
attachment_deleted BOOLEAN NOT NULL,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
"user" TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
|
published BOOLEAN NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user ON messages ("user");
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
|
CREATE TABLE IF NOT EXISTS stats (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value INT
|
||||||
|
);
|
||||||
|
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
|
||||||
|
pgSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pgMessageCacheQueries = &messageCacheQueries{
|
||||||
|
insertMessage: `
|
||||||
|
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, "user", content_type, encoding, published)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||||
|
`,
|
||||||
|
deleteMessage: `DELETE FROM messages WHERE mid = $1`,
|
||||||
|
updateMessagesForTopicExpiry: `UPDATE messages SET expires = $1 WHERE topic = $2`,
|
||||||
|
selectRowIDFromMessageID: `SELECT id FROM messages WHERE mid = $1`, // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
|
selectMessagesByID: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, "user", content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE mid = $1
|
||||||
|
`,
|
||||||
|
selectMessagesSinceTime: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, "user", content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = $1 AND time >= $2 AND published = TRUE
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesSinceTimeIncludeScheduled: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, "user", content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = $1 AND time >= $2
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesSinceID: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, "user", content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = $1 AND id > $2 AND published = TRUE
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesSinceIDIncludeScheduled: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, "user", content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = $1 AND (id > $2 OR published = FALSE)
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesDue: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, "user", content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE time <= $1 AND published = FALSE
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesExpired: `SELECT mid FROM messages WHERE expires <= $1 AND published = TRUE`,
|
||||||
|
updateMessagePublished: `UPDATE messages SET published = TRUE WHERE mid = $1`,
|
||||||
|
selectMessageCountPerTopic: `SELECT topic, COUNT(*) FROM messages GROUP BY topic`,
|
||||||
|
selectTopics: `SELECT topic FROM messages GROUP BY topic`,
|
||||||
|
|
||||||
|
updateAttachmentDeleted: `UPDATE messages SET attachment_deleted = TRUE WHERE mid = $1`,
|
||||||
|
selectAttachmentsExpired: `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE`,
|
||||||
|
selectAttachmentsSizeBySender: `SELECT COALESCE(SUM(attachment_size), 0) FROM messages WHERE "user" = '' AND sender = $1 AND attachment_expires >= $2`,
|
||||||
|
selectAttachmentsSizeByUserID: `SELECT COALESCE(SUM(attachment_size), 0) FROM messages WHERE "user" = $1 AND attachment_expires >= $2`,
|
||||||
|
|
||||||
|
selectStats: `SELECT value FROM stats WHERE key = 'messages'`,
|
||||||
|
updateStats: `UPDATE stats SET value = $1 WHERE key = 'messages'`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgMessageCache struct {
|
||||||
|
*commonMessageCache
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ MessageCache = (*pgMessageCache)(nil)
|
||||||
|
|
||||||
|
func newPgMessageCache(connectionString, startupQueries string, batchSize int, batchTimeout time.Duration) (*pgMessageCache, error) {
|
||||||
|
db, err := sql.Open("postgres", connectionString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := setupPgMessagesDB(db, startupQueries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var queue *util.BatchingQueue[*message]
|
||||||
|
if batchSize > 0 || batchTimeout > 0 {
|
||||||
|
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
|
||||||
|
}
|
||||||
|
cache := &pgMessageCache{
|
||||||
|
commonMessageCache: &commonMessageCache{
|
||||||
|
db: db,
|
||||||
|
queue: queue,
|
||||||
|
queries: pgMessageCacheQueries,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
go cache.processMessageBatches()
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPgMessagesDB(db *sql.DB, startupQueries string) error {
|
||||||
|
// Run startup queries
|
||||||
|
if startupQueries != "" {
|
||||||
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If 'messages' table does not exist, this must be a new database
|
||||||
|
rowsMC, err := db.Query(pgSelectMessagesCountQuery)
|
||||||
|
if err != nil {
|
||||||
|
return setupNewPgCacheDB(db)
|
||||||
|
}
|
||||||
|
rowsMC.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// FIXME schema migration
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNewPgCacheDB(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(pgCreateMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
// FIXME
|
||||||
|
if _, err := db.Exec(pgCreateSchemaVersionTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
return nil
|
||||||
|
}
|
||||||
542
server/message_cache_sqlite.go
Normal file
542
server/message_cache_sqlite.go
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||||
|
errMessageNotFound = errors.New("message not found")
|
||||||
|
errNoRows = errors.New("no rows found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Messages cache
|
||||||
|
const (
|
||||||
|
createMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
|
time INT NOT NULL,
|
||||||
|
expires INT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
|
actions TEXT NOT NULL,
|
||||||
|
attachment_name TEXT NOT NULL,
|
||||||
|
attachment_type TEXT NOT NULL,
|
||||||
|
attachment_size INT NOT NULL,
|
||||||
|
attachment_expires INT NOT NULL,
|
||||||
|
attachment_url TEXT NOT NULL,
|
||||||
|
attachment_deleted INT NOT NULL,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
|
published INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
|
CREATE TABLE IF NOT EXISTS stats (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value INT
|
||||||
|
);
|
||||||
|
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sqliteMessageCacheQueries = &messageCacheQueries{
|
||||||
|
insertMessage: `
|
||||||
|
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
deleteMessage: `DELETE FROM messages WHERE mid = ?`,
|
||||||
|
updateMessagesForTopicExpiry: `UPDATE messages SET expires = ? WHERE topic = ?`,
|
||||||
|
selectRowIDFromMessageID: `SELECT id FROM messages WHERE mid = ?`, // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
|
selectMessagesByID: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE mid = ?
|
||||||
|
`,
|
||||||
|
selectMessagesSinceTime: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesSinceTimeIncludeScheduled: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND time >= ?
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesSinceID: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesSinceIDIncludeScheduled: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesDue: `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE time <= ? AND published = 0
|
||||||
|
ORDER BY time, id
|
||||||
|
`,
|
||||||
|
selectMessagesExpired: `SELECT mid FROM messages WHERE expires <= ? AND published = 1`,
|
||||||
|
updateMessagePublished: `UPDATE messages SET published = 1 WHERE mid = ?`,
|
||||||
|
selectMessageCountPerTopic: `SELECT topic, COUNT(*) FROM messages GROUP BY topic`,
|
||||||
|
selectTopics: `SELECT topic FROM messages GROUP BY topic`,
|
||||||
|
|
||||||
|
updateAttachmentDeleted: `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`,
|
||||||
|
selectAttachmentsExpired: `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`,
|
||||||
|
selectAttachmentsSizeBySender: `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`,
|
||||||
|
selectAttachmentsSizeByUserID: `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`,
|
||||||
|
|
||||||
|
selectStats: `SELECT value FROM stats WHERE key = 'messages'`,
|
||||||
|
updateStats: `UPDATE stats SET value = ? WHERE key = 'messages'`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schema management queries
|
||||||
|
const (
|
||||||
|
currentSchemaVersion = 12
|
||||||
|
createSchemaVersionTableQuery = `
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
version INT NOT NULL
|
||||||
|
);
|
||||||
|
`
|
||||||
|
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
|
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||||
|
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
|
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
|
|
||||||
|
// 0 -> 1
|
||||||
|
migrate0To1AlterMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
||||||
|
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 1 -> 2
|
||||||
|
migrate1To2AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
||||||
|
`
|
||||||
|
|
||||||
|
// 2 -> 3
|
||||||
|
migrate2To3AlterMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
// 3 -> 4
|
||||||
|
migrate3To4AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
|
|
||||||
|
// 4 -> 5
|
||||||
|
migrate4To5AlterMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
attachment_name TEXT NOT NULL,
|
||||||
|
attachment_type TEXT NOT NULL,
|
||||||
|
attachment_size INT NOT NULL,
|
||||||
|
attachment_expires INT NOT NULL,
|
||||||
|
attachment_url TEXT NOT NULL,
|
||||||
|
attachment_owner TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
|
published INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);
|
||||||
|
INSERT
|
||||||
|
INTO messages_new (
|
||||||
|
mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||||
|
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||||
|
SELECT
|
||||||
|
id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||||
|
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published
|
||||||
|
FROM messages;
|
||||||
|
DROP TABLE messages;
|
||||||
|
ALTER TABLE messages_new RENAME TO messages;
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 5 -> 6
|
||||||
|
migrate5To6AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
|
|
||||||
|
// 6 -> 7
|
||||||
|
migrate6To7AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 7 -> 8
|
||||||
|
migrate7To8AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
|
|
||||||
|
// 8 -> 9
|
||||||
|
migrate8To9AlterMessagesTableQuery = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
|
`
|
||||||
|
|
||||||
|
// 9 -> 10
|
||||||
|
migrate9To10AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
||||||
|
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
|
`
|
||||||
|
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
||||||
|
|
||||||
|
// 10 -> 11
|
||||||
|
migrate10To11AlterMessagesTableQuery = `
|
||||||
|
CREATE TABLE IF NOT EXISTS stats (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value INT
|
||||||
|
);
|
||||||
|
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||||
|
`
|
||||||
|
|
||||||
|
// 11 -> 12
|
||||||
|
migrate11To12AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
||||||
|
0: migrateFrom0,
|
||||||
|
1: migrateFrom1,
|
||||||
|
2: migrateFrom2,
|
||||||
|
3: migrateFrom3,
|
||||||
|
4: migrateFrom4,
|
||||||
|
5: migrateFrom5,
|
||||||
|
6: migrateFrom6,
|
||||||
|
7: migrateFrom7,
|
||||||
|
8: migrateFrom8,
|
||||||
|
9: migrateFrom9,
|
||||||
|
10: migrateFrom10,
|
||||||
|
11: migrateFrom11,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteMessageCache struct {
|
||||||
|
*commonMessageCache
|
||||||
|
nop bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ MessageCache = (*sqliteMessageCache)(nil)
|
||||||
|
|
||||||
|
// newSqliteMessageCache creates a SQLite file-backed cache
|
||||||
|
func newSqliteMessageCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*sqliteMessageCache, error) {
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var queue *util.BatchingQueue[*message]
|
||||||
|
if batchSize > 0 || batchTimeout > 0 {
|
||||||
|
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
|
||||||
|
}
|
||||||
|
cache := &sqliteMessageCache{
|
||||||
|
commonMessageCache: &commonMessageCache{
|
||||||
|
db: db,
|
||||||
|
queue: queue,
|
||||||
|
queries: sqliteMessageCacheQueries,
|
||||||
|
},
|
||||||
|
nop: nop,
|
||||||
|
}
|
||||||
|
go cache.processMessageBatches()
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMemCache creates an in-memory cache
|
||||||
|
func newMemCache() (*sqliteMessageCache, error) {
|
||||||
|
return newSqliteMessageCache(createMemoryFilename(), "", 0, 0, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newNopCache creates an in-memory cache that discards all messages;
|
||||||
|
// it is always empty and can be used if caching is entirely disabled
|
||||||
|
func newNopCache() (*sqliteMessageCache, error) {
|
||||||
|
return newSqliteMessageCache(createMemoryFilename(), "", 0, 0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||||
|
// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory
|
||||||
|
// sql database, so if the stdlib's sql engine happens to open another connection and
|
||||||
|
// you've only specified ":memory:", that connection will see a brand new database.
|
||||||
|
// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared").
|
||||||
|
// Every connection to this string will point to the same in-memory database."
|
||||||
|
func createMemoryFilename() string {
|
||||||
|
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously.
|
||||||
|
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
|
||||||
|
func (c *sqliteMessageCache) AddMessage(m *message) error {
|
||||||
|
if c.nop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.commonMessageCache.AddMessage(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||||
|
// Run startup queries
|
||||||
|
if startupQueries != "" {
|
||||||
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If 'messages' table does not exist, this must be a new database
|
||||||
|
rowsMC, err := db.Query(selectMessagesCountQuery)
|
||||||
|
if err != nil {
|
||||||
|
return setupNewCacheDB(db)
|
||||||
|
}
|
||||||
|
rowsMC.Close()
|
||||||
|
|
||||||
|
// If 'messages' table exists, check 'schemaVersion' table
|
||||||
|
schemaVersion := 0
|
||||||
|
rowsSV, err := db.Query(selectSchemaVersionQuery)
|
||||||
|
if err == nil {
|
||||||
|
defer rowsSV.Close()
|
||||||
|
if !rowsSV.Next() {
|
||||||
|
return errors.New("cannot determine schema version: cache file may be corrupt")
|
||||||
|
}
|
||||||
|
if err := rowsSV.Scan(&schemaVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rowsSV.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do migrations
|
||||||
|
if schemaVersion == currentSchemaVersion {
|
||||||
|
return nil
|
||||||
|
} else if schemaVersion > currentSchemaVersion {
|
||||||
|
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion)
|
||||||
|
}
|
||||||
|
for i := schemaVersion; i < currentSchemaVersion; i++ {
|
||||||
|
fn, ok := migrations[i]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||||
|
} else if err := fn(db, cacheDuration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNewCacheDB(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(createMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
||||||
|
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
||||||
|
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
||||||
|
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
||||||
|
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
||||||
|
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
||||||
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
||||||
|
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
||||||
|
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
||||||
|
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate9To10AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom10(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 11); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom11(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate11To12AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 12); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
254
server/message_cache_sqlite_test.go
Normal file
254
server/message_cache_sqlite_test.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 0" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id VARCHAR(20) PRIMARY KEY,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic VARCHAR(64) NOT NULL,
|
||||||
|
message VARCHAR(1024) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of messages
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||||
|
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
require.Nil(t, db.Close())
|
||||||
|
|
||||||
|
// Create cache to trigger migration
|
||||||
|
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||||
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 10, len(messages))
|
||||||
|
require.Equal(t, "some message 5", messages[5].Message)
|
||||||
|
require.Equal(t, "", messages[5].Title)
|
||||||
|
require.Nil(t, messages[5].Tags)
|
||||||
|
require.Equal(t, 0, messages[5].Priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 1" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id VARCHAR(20) PRIMARY KEY,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic VARCHAR(64) NOT NULL,
|
||||||
|
message VARCHAR(512) NOT NULL,
|
||||||
|
title VARCHAR(256) NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags VARCHAR(256) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
version INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of messages
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
require.Nil(t, db.Close())
|
||||||
|
|
||||||
|
// Create cache to trigger migration
|
||||||
|
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||||
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
|
// Add delayed message
|
||||||
|
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
||||||
|
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||||
|
require.Nil(t, c.AddMessage(delayedMessage))
|
||||||
|
|
||||||
|
// 10, not 11!
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 10, len(messages))
|
||||||
|
|
||||||
|
// 11!
|
||||||
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 11, len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From9(t *testing.T) {
|
||||||
|
// This primarily tests the awkward migration that introduces the "expires" column.
|
||||||
|
// The migration logic has to update the column, using the existing "cache-duration" value.
|
||||||
|
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 8" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
|
actions TEXT NOT NULL,
|
||||||
|
attachment_name TEXT NOT NULL,
|
||||||
|
attachment_type TEXT NOT NULL,
|
||||||
|
attachment_size INT NOT NULL,
|
||||||
|
attachment_expires INT NOT NULL,
|
||||||
|
attachment_url TEXT NOT NULL,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
|
published INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
version INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of messages
|
||||||
|
insertQuery := `
|
||||||
|
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err = db.Exec(
|
||||||
|
insertQuery,
|
||||||
|
fmt.Sprintf("abcd%d", i),
|
||||||
|
time.Now().Unix(),
|
||||||
|
"mytopic",
|
||||||
|
fmt.Sprintf("some message %d", i),
|
||||||
|
"", // title
|
||||||
|
0, // priority
|
||||||
|
"", // tags
|
||||||
|
"", // click
|
||||||
|
"", // icon
|
||||||
|
"", // actions
|
||||||
|
"", // attachment_name
|
||||||
|
"", // attachment_type
|
||||||
|
0, // attachment_size
|
||||||
|
0, // attachment_type
|
||||||
|
"", // attachment_url
|
||||||
|
"9.9.9.9", // sender
|
||||||
|
"", // encoding
|
||||||
|
1, // published
|
||||||
|
)
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache to trigger migration
|
||||||
|
cacheDuration := 17 * time.Hour
|
||||||
|
c, err := newSqliteMessageCache(filename, "", cacheDuration, 0, 0, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
|
// Check version
|
||||||
|
rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
var version int
|
||||||
|
require.Nil(t, rows.Scan(&version))
|
||||||
|
require.Equal(t, currentSchemaVersion, version)
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 10, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
|
||||||
|
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
startupQueries := `pragma journal_mode = WAL;
|
||||||
|
pragma synchronous = normal;
|
||||||
|
pragma temp_store = memory;`
|
||||||
|
db, err := newSqliteMessageCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||||
|
require.FileExists(t, filename)
|
||||||
|
require.FileExists(t, filename+"-wal")
|
||||||
|
require.FileExists(t, filename+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
startupQueries := ""
|
||||||
|
db, err := newSqliteMessageCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||||
|
require.FileExists(t, filename)
|
||||||
|
require.NoFileExists(t, filename+"-wal")
|
||||||
|
require.NoFileExists(t, filename+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
startupQueries := `xx error`
|
||||||
|
_, err := newSqliteMessageCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_NopCache(t *testing.T) {
|
||||||
|
c, _ := newNopCache()
|
||||||
|
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Empty(t, messages)
|
||||||
|
|
||||||
|
topics, err := c.Topics()
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Empty(t, topics)
|
||||||
|
}
|
||||||
|
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||||
|
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
|
||||||
|
var schemaVersion int
|
||||||
|
require.Nil(t, rows.Scan(&schemaVersion))
|
||||||
|
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||||
|
require.Nil(t, rows.Close())
|
||||||
|
}
|
||||||
@@ -2,25 +2,17 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSqliteCache_Messages(t *testing.T) {
|
func TestCache_Messages(t *testing.T) {
|
||||||
testCacheMessages(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Messages(t *testing.T) {
|
|
||||||
testCacheMessages(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheMessages(t *testing.T, c *messageCache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "my message")
|
m1 := newDefaultMessage("mytopic", "my message")
|
||||||
m1.Time = 1
|
m1.Time = 1
|
||||||
|
|
||||||
@@ -84,17 +76,11 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
|||||||
// non-existing: since all
|
// non-existing: since all
|
||||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||||
require.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
func TestCache_MessagesScheduled(t *testing.T) {
|
||||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_MessagesScheduled(t *testing.T) {
|
|
||||||
testCacheMessagesScheduled(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "message 1")
|
m1 := newDefaultMessage("mytopic", "message 1")
|
||||||
m2 := newDefaultMessage("mytopic", "message 2")
|
m2 := newDefaultMessage("mytopic", "message 2")
|
||||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||||
@@ -118,17 +104,11 @@ func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
|
|||||||
|
|
||||||
messages, _ = c.MessagesDue()
|
messages, _ = c.MessagesDue()
|
||||||
require.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Topics(t *testing.T) {
|
func TestCache_Topics(t *testing.T) {
|
||||||
testCacheTopics(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Topics(t *testing.T) {
|
|
||||||
testCacheTopics(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheTopics(t *testing.T, c *messageCache) {
|
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||||
@@ -141,17 +121,11 @@ func testCacheTopics(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, 2, len(topics))
|
require.Equal(t, 2, len(topics))
|
||||||
require.Equal(t, "topic1", topics["topic1"].ID)
|
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||||
require.Equal(t, "topic2", topics["topic2"].ID)
|
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
func TestCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
|
||||||
testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
|
|
||||||
m := newDefaultMessage("mytopic", "some message")
|
m := newDefaultMessage("mytopic", "some message")
|
||||||
m.Tags = []string{"tag1", "tag2"}
|
m.Tags = []string{"tag1", "tag2"}
|
||||||
m.Priority = 5
|
m.Priority = 5
|
||||||
@@ -162,17 +136,11 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||||
require.Equal(t, 5, messages[0].Priority)
|
require.Equal(t, 5, messages[0].Priority)
|
||||||
require.Equal(t, "some title", messages[0].Title)
|
require.Equal(t, "some title", messages[0].Title)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_MessagesSinceID(t *testing.T) {
|
func TestCache_MessagesSinceID(t *testing.T) {
|
||||||
testCacheMessagesSinceID(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_MessagesSinceID(t *testing.T) {
|
|
||||||
testCacheMessagesSinceID(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "message 1")
|
m1 := newDefaultMessage("mytopic", "message 1")
|
||||||
m1.Time = 100
|
m1.Time = 100
|
||||||
m2 := newDefaultMessage("mytopic", "message 2")
|
m2 := newDefaultMessage("mytopic", "message 2")
|
||||||
@@ -232,17 +200,11 @@ func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, 2, len(messages))
|
require.Equal(t, 2, len(messages))
|
||||||
require.Equal(t, "message 5", messages[0].Message)
|
require.Equal(t, "message 5", messages[0].Message)
|
||||||
require.Equal(t, "message 3", messages[1].Message)
|
require.Equal(t, "message 3", messages[1].Message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Prune(t *testing.T) {
|
func TestCache_Prune(t *testing.T) {
|
||||||
testCachePrune(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Prune(t *testing.T) {
|
|
||||||
testCachePrune(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCachePrune(t *testing.T, c *messageCache) {
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
m1 := newDefaultMessage("mytopic", "my message")
|
m1 := newDefaultMessage("mytopic", "my message")
|
||||||
@@ -279,17 +241,11 @@ func testCachePrune(t *testing.T, c *messageCache) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
require.Equal(t, "my other message", messages[0].Message)
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Attachments(t *testing.T) {
|
func TestCache_Attachments(t *testing.T) {
|
||||||
testCacheAttachments(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Attachments(t *testing.T) {
|
|
||||||
testCacheAttachments(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheAttachments(t *testing.T, c *messageCache) {
|
|
||||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||||
m := newDefaultMessage("mytopic", "flower for you")
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
m.ID = "m1"
|
m.ID = "m1"
|
||||||
@@ -361,17 +317,11 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa")
|
size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(20000), size)
|
require.Equal(t, int64(20000), size)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Attachments_Expired(t *testing.T) {
|
func TestCache_AttachmentsExpired(t *testing.T) {
|
||||||
testCacheAttachmentsExpired(t, newSqliteTestCache(t))
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Attachments_Expired(t *testing.T) {
|
|
||||||
testCacheAttachmentsExpired(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
|
||||||
m := newDefaultMessage("mytopic", "flower for you")
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
m.ID = "m1"
|
m.ID = "m1"
|
||||||
m.Expires = time.Now().Add(time.Hour).Unix()
|
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||||
@@ -416,238 +366,11 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, len(ids))
|
require.Equal(t, 1, len(ids))
|
||||||
require.Equal(t, "m4", ids[0])
|
require.Equal(t, "m4", ids[0])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
func TestCache_Sender(t *testing.T) {
|
||||||
filename := newSqliteTestCacheFile(t)
|
runMessageCacheTest(t, func(t *testing.T, c MessageCache) {
|
||||||
db, err := sql.Open("sqlite3", filename)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Create "version 0" schema
|
|
||||||
_, err = db.Exec(`
|
|
||||||
BEGIN;
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id VARCHAR(20) PRIMARY KEY,
|
|
||||||
time INT NOT NULL,
|
|
||||||
topic VARCHAR(64) NOT NULL,
|
|
||||||
message VARCHAR(1024) NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
|
||||||
COMMIT;
|
|
||||||
`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Insert a bunch of messages
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
|
||||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
|
||||||
require.Nil(t, err)
|
|
||||||
}
|
|
||||||
require.Nil(t, db.Close())
|
|
||||||
|
|
||||||
// Create cache to trigger migration
|
|
||||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
|
||||||
checkSchemaVersion(t, c.db)
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 10, len(messages))
|
|
||||||
require.Equal(t, "some message 5", messages[5].Message)
|
|
||||||
require.Equal(t, "", messages[5].Title)
|
|
||||||
require.Nil(t, messages[5].Tags)
|
|
||||||
require.Equal(t, 0, messages[5].Priority)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
db, err := sql.Open("sqlite3", filename)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Create "version 1" schema
|
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id VARCHAR(20) PRIMARY KEY,
|
|
||||||
time INT NOT NULL,
|
|
||||||
topic VARCHAR(64) NOT NULL,
|
|
||||||
message VARCHAR(512) NOT NULL,
|
|
||||||
title VARCHAR(256) NOT NULL,
|
|
||||||
priority INT NOT NULL,
|
|
||||||
tags VARCHAR(256) NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
|
||||||
id INT PRIMARY KEY,
|
|
||||||
version INT NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
|
||||||
`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Insert a bunch of messages
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
|
||||||
require.Nil(t, err)
|
|
||||||
}
|
|
||||||
require.Nil(t, db.Close())
|
|
||||||
|
|
||||||
// Create cache to trigger migration
|
|
||||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
|
||||||
checkSchemaVersion(t, c.db)
|
|
||||||
|
|
||||||
// Add delayed message
|
|
||||||
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
|
||||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
|
||||||
require.Nil(t, c.AddMessage(delayedMessage))
|
|
||||||
|
|
||||||
// 10, not 11!
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 10, len(messages))
|
|
||||||
|
|
||||||
// 11!
|
|
||||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 11, len(messages))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From9(t *testing.T) {
|
|
||||||
// This primarily tests the awkward migration that introduces the "expires" column.
|
|
||||||
// The migration logic has to update the column, using the existing "cache-duration" value.
|
|
||||||
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
db, err := sql.Open("sqlite3", filename)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Create "version 8" schema
|
|
||||||
_, err = db.Exec(`
|
|
||||||
BEGIN;
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mid TEXT NOT NULL,
|
|
||||||
time INT NOT NULL,
|
|
||||||
topic TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
priority INT NOT NULL,
|
|
||||||
tags TEXT NOT NULL,
|
|
||||||
click TEXT NOT NULL,
|
|
||||||
icon TEXT NOT NULL,
|
|
||||||
actions TEXT NOT NULL,
|
|
||||||
attachment_name TEXT NOT NULL,
|
|
||||||
attachment_type TEXT NOT NULL,
|
|
||||||
attachment_size INT NOT NULL,
|
|
||||||
attachment_expires INT NOT NULL,
|
|
||||||
attachment_url TEXT NOT NULL,
|
|
||||||
sender TEXT NOT NULL,
|
|
||||||
encoding TEXT NOT NULL,
|
|
||||||
published INT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
|
||||||
id INT PRIMARY KEY,
|
|
||||||
version INT NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
|
|
||||||
COMMIT;
|
|
||||||
`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Insert a bunch of messages
|
|
||||||
insertQuery := `
|
|
||||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
_, err = db.Exec(
|
|
||||||
insertQuery,
|
|
||||||
fmt.Sprintf("abcd%d", i),
|
|
||||||
time.Now().Unix(),
|
|
||||||
"mytopic",
|
|
||||||
fmt.Sprintf("some message %d", i),
|
|
||||||
"", // title
|
|
||||||
0, // priority
|
|
||||||
"", // tags
|
|
||||||
"", // click
|
|
||||||
"", // icon
|
|
||||||
"", // actions
|
|
||||||
"", // attachment_name
|
|
||||||
"", // attachment_type
|
|
||||||
0, // attachment_size
|
|
||||||
0, // attachment_type
|
|
||||||
"", // attachment_url
|
|
||||||
"9.9.9.9", // sender
|
|
||||||
"", // encoding
|
|
||||||
1, // published
|
|
||||||
)
|
|
||||||
require.Nil(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create cache to trigger migration
|
|
||||||
cacheDuration := 17 * time.Hour
|
|
||||||
c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
checkSchemaVersion(t, c.db)
|
|
||||||
|
|
||||||
// Check version
|
|
||||||
rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.True(t, rows.Next())
|
|
||||||
var version int
|
|
||||||
require.Nil(t, rows.Scan(&version))
|
|
||||||
require.Equal(t, currentSchemaVersion, version)
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 10, len(messages))
|
|
||||||
for _, m := range messages {
|
|
||||||
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
|
|
||||||
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
startupQueries := `pragma journal_mode = WAL;
|
|
||||||
pragma synchronous = normal;
|
|
||||||
pragma temp_store = memory;`
|
|
||||||
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
|
||||||
require.FileExists(t, filename)
|
|
||||||
require.FileExists(t, filename+"-wal")
|
|
||||||
require.FileExists(t, filename+"-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
startupQueries := ""
|
|
||||||
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
|
||||||
require.FileExists(t, filename)
|
|
||||||
require.NoFileExists(t, filename+"-wal")
|
|
||||||
require.NoFileExists(t, filename+"-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
startupQueries := `xx error`
|
|
||||||
_, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Sender(t *testing.T) {
|
|
||||||
testSender(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Sender(t *testing.T) {
|
|
||||||
testSender(t, newMemTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSender(t *testing.T, c *messageCache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "mymessage")
|
m1 := newDefaultMessage("mytopic", "mymessage")
|
||||||
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||||
require.Nil(t, c.AddMessage(m1))
|
require.Nil(t, c.AddMessage(m1))
|
||||||
@@ -660,34 +383,11 @@ func testSender(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, 2, len(messages))
|
require.Equal(t, 2, len(messages))
|
||||||
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
|
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
|
||||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
func newSqliteTestCache(t *testing.T) *sqliteMessageCache {
|
||||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
c, err := newSqliteMessageCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false)
|
||||||
require.Nil(t, err)
|
|
||||||
require.True(t, rows.Next())
|
|
||||||
|
|
||||||
var schemaVersion int
|
|
||||||
require.Nil(t, rows.Scan(&schemaVersion))
|
|
||||||
require.Equal(t, currentSchemaVersion, schemaVersion)
|
|
||||||
require.Nil(t, rows.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_NopCache(t *testing.T) {
|
|
||||||
c, _ := newNopCache()
|
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Empty(t, messages)
|
|
||||||
|
|
||||||
topics, err := c.Topics()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Empty(t, topics)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
|
||||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -698,18 +398,45 @@ func newSqliteTestCacheFile(t *testing.T) string {
|
|||||||
return filepath.Join(t.TempDir(), "cache.db")
|
return filepath.Join(t.TempDir(), "cache.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *sqliteMessageCache {
|
||||||
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
c, err := newSqliteMessageCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemTestCache(t *testing.T) *messageCache {
|
func newMemTestCache(t *testing.T) MessageCache {
|
||||||
c, err := newMemCache()
|
c, err := newMemCache()
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newPgTestCache(t *testing.T) MessageCache {
|
||||||
|
connectionString := os.Getenv("NTFY_TEST_MESSAGES_CACHE_PG_CONNECTION_STRING")
|
||||||
|
if connectionString == "" {
|
||||||
|
t.Skip("Skipping test, because NTFY_TEST_MESSAGES_CACHE_PG_CONNECTION_STRING not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("postgres", connectionString)
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = db.Exec("DROP TABLE IF EXISTS messages")
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = db.Exec("DROP TABLE IF EXISTS stats")
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = db.Exec("DROP TABLE IF EXISTS schemaVersion")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, db.Close())
|
||||||
|
c, err := newPgMessageCache(connectionString, "", 0, 0)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMessageCacheTest(t *testing.T, f func(t *testing.T, c MessageCache)) {
|
||||||
|
t.Run(t.Name()+"_sqlite", func(t *testing.T) {
|
||||||
|
f(t, newSqliteTestCache(t))
|
||||||
|
})
|
||||||
|
t.Run(t.Name()+"_mem", func(t *testing.T) {
|
||||||
|
f(t, newMemTestCache(t))
|
||||||
|
})
|
||||||
|
t.Run(t.Name()+"_pg", func(t *testing.T) {
|
||||||
|
f(t, newPgTestCache(t))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
120
server/server.go
120
server/server.go
@@ -23,6 +23,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ type Server struct {
|
|||||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||||
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
||||||
userManager *user.Manager // Might be nil!
|
userManager *user.Manager // Might be nil!
|
||||||
messageCache *messageCache // Database that stores the messages
|
messageCache MessageCache // Database that stores the messages
|
||||||
webPush *webPushStore // Database that stores web push subscriptions
|
webPush *webPushStore // Database that stores web push subscriptions
|
||||||
fileCache *fileCache // File system based cache that stores attachments
|
fileCache *fileCache // File system based cache that stores attachments
|
||||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||||
@@ -123,15 +124,22 @@ var (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
firebaseControlTopic = "~control" // See Android if changed
|
firebaseControlTopic = "~control" // See Android if changed
|
||||||
firebasePollTopic = "~poll" // See iOS if changed
|
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||||
emptyMessageBody = "triggered" // Used if message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
newMessageBody = "New message" // Used in poll requests as generic message
|
newMessageBody = "New message" // Used in poll requests as generic message
|
||||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||||
jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body
|
jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher)
|
||||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||||
|
templateMaxExecutionTime = 100 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
||||||
|
// are not useful, and seem potentially troublesome.
|
||||||
|
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
@@ -218,11 +226,13 @@ func New(conf *Config) (*Server, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMessageCache(conf *Config) (*messageCache, error) {
|
func createMessageCache(conf *Config) (MessageCache, error) {
|
||||||
if conf.CacheDuration == 0 {
|
if conf.CacheDuration == 0 {
|
||||||
return newNopCache()
|
return newNopCache()
|
||||||
|
} else if strings.HasPrefix(conf.CacheFile, "postgres:") {
|
||||||
|
return newPgMessageCache(strings.TrimPrefix(conf.CacheFile, "postgres:"), conf.CacheStartupQueries, conf.CacheBatchSize, conf.CacheBatchTimeout)
|
||||||
} else if conf.CacheFile != "" {
|
} else if conf.CacheFile != "" {
|
||||||
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
|
return newSqliteMessageCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
|
||||||
}
|
}
|
||||||
return newMemCache()
|
return newMemCache()
|
||||||
}
|
}
|
||||||
@@ -673,7 +683,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
|
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
|
||||||
// - and also uses the higher bandwidth limits of a paying user
|
// - and also uses the higher bandwidth limits of a paying user
|
||||||
m, err := s.messageCache.Message(messageID)
|
m, err := s.messageCache.Message(messageID)
|
||||||
if err == errMessageNotFound {
|
if errors.Is(err, errMessageNotFound) {
|
||||||
if s.config.CacheBatchTimeout > 0 {
|
if s.config.CacheBatchTimeout > 0 {
|
||||||
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
||||||
// and messages are persisted asynchronously, retry fetching from the database
|
// and messages are persisted asynchronously, retry fetching from the database
|
||||||
@@ -738,7 +748,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, "")
|
m := newDefaultMessage(t.ID, "")
|
||||||
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
|
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e.With(t)
|
return nil, e.With(t)
|
||||||
}
|
}
|
||||||
@@ -769,7 +779,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
if cache {
|
if cache {
|
||||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||||
}
|
}
|
||||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
@@ -872,7 +882,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) {
|
|||||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||||
minc(metricFirebasePublishedFailure)
|
minc(metricFirebasePublishedFailure)
|
||||||
if err == errFirebaseTemporarilyBanned {
|
if errors.Is(err, errFirebaseTemporarilyBanned) {
|
||||||
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
||||||
} else {
|
} else {
|
||||||
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
||||||
@@ -924,7 +934,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
|
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
m.Title = readParam(r, "x-title", "title", "t")
|
||||||
@@ -940,7 +950,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
}
|
}
|
||||||
if attach != "" {
|
if attach != "" {
|
||||||
if !urlRegex.MatchString(attach) {
|
if !urlRegex.MatchString(attach) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
@@ -958,19 +968,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
if !urlRegex.MatchString(icon) {
|
if !urlRegex.MatchString(icon) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
|
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
||||||
}
|
}
|
||||||
m.Icon = icon
|
m.Icon = icon
|
||||||
}
|
}
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestEmailDisabled
|
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
call = readParam(r, "x-call", "call")
|
call = readParam(r, "x-call", "call")
|
||||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
|
||||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
|
||||||
}
|
}
|
||||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
@@ -979,27 +989,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
var e error
|
var e error
|
||||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCache
|
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
}
|
}
|
||||||
if call != "" {
|
if call != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||||
}
|
}
|
||||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
|
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
|
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
|
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
@@ -1007,13 +1017,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, e = parseActions(actionsStr)
|
m.Actions, e = parseActions(actionsStr)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||||
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||||
m.ContentType = "text/markdown"
|
m.ContentType = "text/markdown"
|
||||||
}
|
}
|
||||||
|
template = readBoolParam(r, false, "x-template", "template", "tpl")
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
if unifiedpush {
|
if unifiedpush {
|
||||||
firebase = false
|
firebase = false
|
||||||
@@ -1025,7 +1036,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
cache = false
|
cache = false
|
||||||
email = ""
|
email = ""
|
||||||
}
|
}
|
||||||
return cache, firebase, email, call, unifiedpush, nil
|
return cache, firebase, email, call, template, unifiedpush, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||||
@@ -1033,16 +1044,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
||||||
// If a message is flagged as poll request, the body does not matter and is discarded
|
// If a message is flagged as poll request, the body does not matter and is discarded
|
||||||
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||||
// If body is binary, encode as base64, if not do not encode
|
// If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim
|
||||||
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||||
// Body must be a message, because we attached an external URL
|
// Body must be a message, because we attached an external URL
|
||||||
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||||
// Body must be attachment, because we passed a filename
|
// Body must be attachment, because we passed a filename
|
||||||
// 5. curl -T file.txt ntfy.sh/mytopic
|
// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
// If templating is enabled, read up to 32k and treat message body as JSON
|
||||||
// 6. curl -T file.txt ntfy.sh/mytopic
|
// 6. curl -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is > message limit, treat it as an attachment
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||||
|
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
||||||
|
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
|
||||||
if m.Event == pollRequestEvent { // Case 1
|
if m.Event == pollRequestEvent { // Case 1
|
||||||
return s.handleBodyDiscard(body)
|
return s.handleBodyDiscard(body)
|
||||||
} else if unifiedpush {
|
} else if unifiedpush {
|
||||||
@@ -1051,10 +1064,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
|||||||
return s.handleBodyAsTextMessage(m, body) // Case 3
|
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||||
|
} else if template {
|
||||||
|
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
||||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 5
|
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||||
}
|
}
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
||||||
@@ -1086,6 +1101,45 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||||
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if body.LimitReached {
|
||||||
|
return errHTTPEntityTooLargeJSONBody
|
||||||
|
}
|
||||||
|
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||||
|
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(m.Message) > s.config.MessageSizeLimit {
|
||||||
|
return errHTTPBadRequestTemplateMessageTooLarge
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTemplate(tpl string, source string) (string, error) {
|
||||||
|
if templateDisallowedRegex.MatchString(tpl) {
|
||||||
|
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||||
|
}
|
||||||
|
var data any
|
||||||
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
|
}
|
||||||
|
t, err := template.New("").Parse(tpl)
|
||||||
|
if err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateInvalid
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateExecuteFailed
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) 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 == "" {
|
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||||
@@ -1128,7 +1182,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||||||
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
||||||
}
|
}
|
||||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||||
if err == util.ErrLimitReached {
|
if errors.Is(err, util.ErrLimitReached) {
|
||||||
return errHTTPEntityTooLargeAttachment.With(m)
|
return errHTTPEntityTooLargeAttachment.With(m)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1473,7 +1527,7 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
// sendOldMessages selects old messages from the sqliteMessageCache and calls sub for each of them. It uses since as the
|
||||||
// marker, returning only messages that are newer than the marker.
|
// marker, returning only messages that are newer than the marker.
|
||||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -45,7 +46,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return errHTTPBadRequest.Wrap("username invalid, or password missing")
|
return errHTTPBadRequest.Wrap("username invalid, or password missing")
|
||||||
}
|
}
|
||||||
u, err := s.userManager.User(req.Username)
|
u, err := s.userManager.User(req.Username)
|
||||||
if err != nil && err != user.ErrUserNotFound {
|
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
|
||||||
return err
|
return err
|
||||||
} else if u != nil {
|
} else if u != nil {
|
||||||
return errHTTPConflictUserExists
|
return errHTTPConflictUserExists
|
||||||
@@ -53,7 +54,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
var tier *user.Tier
|
var tier *user.Tier
|
||||||
if req.Tier != "" {
|
if req.Tier != "" {
|
||||||
tier, err = s.userManager.Tier(req.Tier)
|
tier, err = s.userManager.Tier(req.Tier)
|
||||||
if err == user.ErrTierNotFound {
|
if errors.Is(err, user.ErrTierNotFound) {
|
||||||
return errHTTPBadRequestTierInvalid
|
return errHTTPBadRequestTierInvalid
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,7 +77,7 @@ func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *vi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := s.userManager.User(req.Username)
|
u, err := s.userManager.User(req.Username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return errHTTPBadRequestUserNotFound
|
return errHTTPBadRequestUserNotFound
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,7 +99,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.userManager.User(req.Username)
|
_, err = s.userManager.User(req.Username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return errHTTPBadRequestUserNotFound
|
return errHTTPBadRequestUserNotFound
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -104,9 +105,9 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
|||||||
|
|
||||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
||||||
if err == util.ErrUnmarshalJSON {
|
if errors.Is(err, util.ErrUnmarshalJSON) {
|
||||||
return nil, errHTTPBadRequestJSONInvalid
|
return nil, errHTTPBadRequestJSONInvalid
|
||||||
} else if err == util.ErrTooLargeJSON {
|
} else if errors.Is(err, util.ErrTooLargeJSON) {
|
||||||
return nil, errHTTPEntityTooLargeJSONBody
|
return nil, errHTTPEntityTooLargeJSONBody
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const (
|
|||||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
messageCache *messageCache
|
messageCache MessageCache
|
||||||
userManager *user.Manager // May be nil
|
userManager *user.Manager // May be nil
|
||||||
ip netip.Addr // Visitor IP address
|
ip netip.Addr // Visitor IP address
|
||||||
user *user.User // Only set if authenticated user, otherwise nil
|
user *user.User // Only set if authenticated user, otherwise nil
|
||||||
@@ -114,7 +114,7 @@ const (
|
|||||||
visitorLimitBasisTier = visitorLimitBasis("tier")
|
visitorLimitBasisTier = visitorLimitBasis("tier")
|
||||||
)
|
)
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
func newVisitor(conf *Config, messageCache MessageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||||
var messages, emails, calls int64
|
var messages, emails, calls int64
|
||||||
if user != nil {
|
if user != nil {
|
||||||
messages = user.Stats.Messages
|
messages = user.Stats.Messages
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
|
|||||||
|
|
||||||
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
||||||
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
||||||
port := 10000 + rand.Intn(20000)
|
port := 10000 + rand.Intn(30000)
|
||||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||||
conf.AttachmentCacheDir = t.TempDir()
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,7 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
|||||||
}
|
}
|
||||||
peeked := make([]byte, limit)
|
peeked := make([]byte, limit)
|
||||||
read, err := io.ReadFull(underlying, peeked)
|
read, err := io.ReadFull(underlying, peeked)
|
||||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PeekedReadCloser{
|
return &PeekedReadCloser{
|
||||||
@@ -44,7 +45,7 @@ func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
|
|||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
n, err = r.peeked.Read(p)
|
n, err = r.peeked.Read(p)
|
||||||
if err == io.EOF {
|
if errors.Is(err, io.EOF) {
|
||||||
return r.underlying.Read(p)
|
return r.underlying.Read(p)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
34
util/timeout_writer.go
Normal file
34
util/timeout_writer.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWriteTimeout is returned when a write timed out
|
||||||
|
var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation")
|
||||||
|
|
||||||
|
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
|
||||||
|
type TimeoutWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
timeout time.Duration
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTimeoutWriter creates a new TimeoutWriter
|
||||||
|
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
|
||||||
|
return &TimeoutWriter{
|
||||||
|
writer: w,
|
||||||
|
timeout: timeout,
|
||||||
|
start: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
|
||||||
|
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if time.Since(tw.start) > tw.timeout {
|
||||||
|
return 0, errors.New("write operation failed due to timeout since creation")
|
||||||
|
}
|
||||||
|
return tw.writer.Write(p)
|
||||||
|
}
|
||||||
2601
web/package-lock.json
generated
2601
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"action_bar_clear_notifications": "Премахване на известия",
|
"action_bar_clear_notifications": "Премахване на известия",
|
||||||
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия.",
|
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия",
|
||||||
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
|
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
|
||||||
"notifications_example": "Пример",
|
"notifications_example": "Пример",
|
||||||
"notifications_no_subscriptions_title": "Липсват абонаменти.",
|
"notifications_no_subscriptions_title": "Липсват абонаменти",
|
||||||
"nav_topics_title": "Абонаменти",
|
"nav_topics_title": "Абонаменти",
|
||||||
"action_bar_send_test_notification": "Пробно известие",
|
"action_bar_send_test_notification": "Пробно известие",
|
||||||
"action_bar_unsubscribe": "Отписване",
|
"action_bar_unsubscribe": "Отписване",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
||||||
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
||||||
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
|
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
|
||||||
"publish_dialog_chip_delay_label": "Забавяне на изпращането",
|
"publish_dialog_chip_delay_label": "Отлагане на изпращането",
|
||||||
"publish_dialog_chip_topic_label": "Промяна на темата",
|
"publish_dialog_chip_topic_label": "Промяна на темата",
|
||||||
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
||||||
"publish_dialog_button_cancel": "Отказ",
|
"publish_dialog_button_cancel": "Отказ",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"prefs_notifications_delete_after_never": "Никога",
|
"prefs_notifications_delete_after_never": "Никога",
|
||||||
"prefs_users_add_button": "Добавяне",
|
"prefs_users_add_button": "Добавяне",
|
||||||
"prefs_users_dialog_password_label": "Парола",
|
"prefs_users_dialog_password_label": "Парола",
|
||||||
"alert_not_supported_description": "Мрежовият четец не поддържа известия.",
|
"alert_not_supported_description": "Мрежовият четец не поддържа известия",
|
||||||
"message_bar_type_message": "Въведете съобщение",
|
"message_bar_type_message": "Въведете съобщение",
|
||||||
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
||||||
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
"notifications_click_open_button": "Отваряне",
|
"notifications_click_open_button": "Отваряне",
|
||||||
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
||||||
"notifications_none_for_topic_title": "Темата е все още празна",
|
"notifications_none_for_topic_title": "Темата е все още празна",
|
||||||
"notifications_none_for_any_title": "Липсват известия.",
|
"notifications_none_for_any_title": "Липсват известия",
|
||||||
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
|
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
|
||||||
"notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
"notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
||||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като направите заявка чрез методите PUT или POST ще ги получите тук.",
|
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете или да се абонирате за тема. След това като изпратите съобщение с методите PUT или POST ще го получите тук.",
|
||||||
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
||||||
"publish_dialog_priority_min": "Най-нисък приоритет",
|
"publish_dialog_priority_min": "Най-нисък приоритет",
|
||||||
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
||||||
@@ -84,14 +84,14 @@
|
|||||||
"publish_dialog_topic_label": "Име на темата",
|
"publish_dialog_topic_label": "Име на темата",
|
||||||
"publish_dialog_title_label": "Заглавие",
|
"publish_dialog_title_label": "Заглавие",
|
||||||
"publish_dialog_priority_label": "Приоритет",
|
"publish_dialog_priority_label": "Приоритет",
|
||||||
"publish_dialog_click_placeholder": "Адрес, който се отваря при щракване върху известието",
|
"publish_dialog_click_placeholder": "Адрес, който се отваря при докосване на известието",
|
||||||
"publish_dialog_email_placeholder": "Адрес, към който да бъдат препращани известия, напр. phil@example.com",
|
"publish_dialog_email_placeholder": "Адрес, към който да бъдат препращани известия, напр. phil@example.com",
|
||||||
"publish_dialog_attach_label": "Адрес на прикачения файл",
|
"publish_dialog_attach_label": "Адрес на прикачения файл",
|
||||||
"publish_dialog_filename_placeholder": "Име на прикачения файл",
|
"publish_dialog_filename_placeholder": "Име на прикачения файл",
|
||||||
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
|
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
|
||||||
"prefs_notifications_delete_after_three_hours": "След три часа",
|
"prefs_notifications_delete_after_three_hours": "След три часа",
|
||||||
"publish_dialog_filename_label": "Име на файла",
|
"publish_dialog_filename_label": "Име на файла",
|
||||||
"publish_dialog_delay_label": "Забавяне",
|
"publish_dialog_delay_label": "Отлагане",
|
||||||
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
|
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
|
||||||
"publish_dialog_button_send": "Изпращане",
|
"publish_dialog_button_send": "Изпращане",
|
||||||
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
|
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"subscribe_dialog_login_button_login": "Вход",
|
"subscribe_dialog_login_button_login": "Вход",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
||||||
"prefs_appearance_title": "Външен вид",
|
"prefs_appearance_title": "Външен вид",
|
||||||
"publish_dialog_delay_placeholder": "Забавяне на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
"publish_dialog_delay_placeholder": "Отлагане на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
||||||
"prefs_notifications_delete_after_one_week": "След една седмица",
|
"prefs_notifications_delete_after_one_week": "След една седмица",
|
||||||
"prefs_users_title": "Управление на потребители",
|
"prefs_users_title": "Управление на потребители",
|
||||||
"prefs_users_table_base_url_header": "Адрес на услугата",
|
"prefs_users_table_base_url_header": "Адрес на услугата",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"publish_dialog_topic_reset": "Нулиране на тема",
|
"publish_dialog_topic_reset": "Нулиране на тема",
|
||||||
"publish_dialog_click_reset": "Премахване на адрес",
|
"publish_dialog_click_reset": "Премахване на адрес",
|
||||||
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
||||||
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
|
"publish_dialog_delay_reset": "Премахва отлагането на изпращането",
|
||||||
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
||||||
"emoji_picker_search_clear": "Изчистване на търсенето",
|
"emoji_picker_search_clear": "Изчистване на търсенето",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
||||||
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
||||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
|
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и писма, по-големи прикачени файлове",
|
||||||
"display_name_dialog_placeholder": "Наименование",
|
"display_name_dialog_placeholder": "Наименование",
|
||||||
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
||||||
@@ -380,5 +380,28 @@
|
|||||||
"reservation_delete_dialog_action_delete_title": "Премахване на съобщения и прикачени файлове",
|
"reservation_delete_dialog_action_delete_title": "Премахване на съобщения и прикачени файлове",
|
||||||
"reservation_delete_dialog_action_delete_description": "Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.",
|
"reservation_delete_dialog_action_delete_description": "Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.",
|
||||||
"prefs_reservations_description": "Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
"prefs_reservations_description": "Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||||
"reservation_delete_dialog_description": "С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове."
|
"reservation_delete_dialog_description": "С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове.",
|
||||||
|
"alert_notification_permission_denied_description": "Включете ги от мрежовия четец",
|
||||||
|
"alert_notification_permission_denied_title": "Известията са изключени",
|
||||||
|
"notifications_actions_failed_notification": "Действието е неуспешно",
|
||||||
|
"publish_dialog_checkbox_markdown": "Съобщението е Markdown",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Известията ще бъдат получавани докато приложението за уеб работи (чрез WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Включено за {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Изключено",
|
||||||
|
"prefs_appearance_theme_dark": "Тъмна",
|
||||||
|
"prefs_appearance_theme_light": "Светла",
|
||||||
|
"error_boundary_button_reload_ntfy": "Презареждне на ntfy",
|
||||||
|
"web_push_unknown_notification_title": "Получено е неочаквано известие",
|
||||||
|
"web_push_unknown_notification_body": "Вероятно ще трябва да обновите ntfy като отворите приложението за уеб",
|
||||||
|
"alert_notification_ios_install_required_title": "Необходимо е инсталиране за iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Докоснете бутона Споделяне и Добавяне към началния екран, за да включите известията под iOS",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Известията от други сървъри няма да бъдат получавани ако приложението за уеб не е отворено",
|
||||||
|
"action_bar_mute_notifications": "Заглушаване на известия",
|
||||||
|
"prefs_notifications_web_push_title": "Известия във фонов режим",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
|
||||||
|
"prefs_appearance_theme_title": "Цветова тема",
|
||||||
|
"prefs_appearance_theme_system": "Системна (подразбирана)",
|
||||||
|
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
|
||||||
|
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||||
|
"action_bar_unmute_notifications": "Включване звука на известията"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"message_bar_type_message": "Escriba un mensaje aquí",
|
"message_bar_type_message": "Escriba un mensaje aquí",
|
||||||
"message_bar_error_publishing": "Error al publicar la notificación",
|
"message_bar_error_publishing": "Error al publicar la notificación",
|
||||||
"alert_notification_permission_required_title": "Las notificaciones están deshabilitadas",
|
"alert_notification_permission_required_title": "Las notificaciones están deshabilitadas",
|
||||||
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
|
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones de escritorio",
|
||||||
"nav_button_all_notifications": "Todas las notificaciones",
|
"nav_button_all_notifications": "Todas las notificaciones",
|
||||||
"nav_button_settings": "Ajustes",
|
"nav_button_settings": "Ajustes",
|
||||||
"nav_button_subscribe": "Suscribirse al tópico",
|
"nav_button_subscribe": "Suscribirse al tópico",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"nav_button_publish_message": "Publicar notificación",
|
"nav_button_publish_message": "Publicar notificación",
|
||||||
"notifications_copied_to_clipboard": "Copiado al portapapeles",
|
"notifications_copied_to_clipboard": "Copiado al portapapeles",
|
||||||
"alert_not_supported_title": "Notificaciones no soportadas",
|
"alert_not_supported_title": "Notificaciones no soportadas",
|
||||||
"alert_not_supported_description": "Las notificaciones no están soportadas por tu navegador.",
|
"alert_not_supported_description": "Su navegador no admite notificaciones",
|
||||||
"notifications_tags": "Etiquetas",
|
"notifications_tags": "Etiquetas",
|
||||||
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
|
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
|
||||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||||
@@ -381,5 +381,28 @@
|
|||||||
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
|
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
|
||||||
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
|
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados"
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados",
|
||||||
|
"action_bar_mute_notifications": "Silenciar Notificaciones",
|
||||||
|
"action_bar_unmute_notifications": "Reactivar notificaciones",
|
||||||
|
"alert_notification_permission_denied_title": "Notificaciones bloqueadas",
|
||||||
|
"alert_notification_permission_denied_description": "Porfavor, reactivelas en su navegador",
|
||||||
|
"alert_notification_ios_install_required_title": "Requiere instalacion de iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Haz click en el icono de compartir y Añadir a pantalla de inicio para activar las notificaciones de iOS",
|
||||||
|
"notifications_actions_failed_notification": "Acción fallida",
|
||||||
|
"publish_dialog_checkbox_markdown": "Formatear como Markdown",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Las notificaciones de otros servidores no se recibirán cuando la aplicación web no esté abierta",
|
||||||
|
"prefs_notifications_web_push_title": "Notificaciones en segundo plano",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled": "Desactivado",
|
||||||
|
"prefs_appearance_theme_title": "Tema",
|
||||||
|
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||||
|
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
|
||||||
|
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Activado para {{server}}",
|
||||||
|
"prefs_appearance_theme_light": "Claro",
|
||||||
|
"prefs_appearance_theme_dark": "Oscuro",
|
||||||
|
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibiendo notificaciones",
|
||||||
|
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
|
||||||
|
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"nav_button_muted": "Notificacións acaladas",
|
"nav_button_muted": "Notificacións acaladas",
|
||||||
"nav_button_connecting": "conectando",
|
"nav_button_connecting": "conectando",
|
||||||
"nav_upgrade_banner_label": "Mellorar a ntfy Pro",
|
"nav_upgrade_banner_label": "Mellorar a ntfy Pro",
|
||||||
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións.",
|
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións",
|
||||||
"notifications_priority_x": "Prioridade {{priority}}",
|
"notifications_priority_x": "Prioridade {{priority}}",
|
||||||
"notifications_attachment_link_expires": "a ligazón caduca o {{date}}",
|
"notifications_attachment_link_expires": "a ligazón caduca o {{date}}",
|
||||||
"notifications_attachment_link_expired": "a ligazón de descarga caducou",
|
"notifications_attachment_link_expired": "a ligazón de descarga caducou",
|
||||||
@@ -380,5 +380,31 @@
|
|||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Chámame",
|
"account_basics_phone_numbers_dialog_verify_button_call": "Chámame",
|
||||||
"account_usage_emails_title": "Emails enviados",
|
"account_usage_emails_title": "Emails enviados",
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse."
|
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse.",
|
||||||
|
"action_bar_mute_notifications": "Acalar notificacións",
|
||||||
|
"action_bar_unmute_notifications": "Reactivar notificacións",
|
||||||
|
"alert_notification_permission_required_title": "Notificacións desactivadas",
|
||||||
|
"alert_notification_permission_required_description": "Concederlle permisos ao navegador para mostrar notificacións de escritorio",
|
||||||
|
"alert_notification_permission_required_button": "Conceder",
|
||||||
|
"alert_notification_permission_denied_title": "Notificacións bloqueadas",
|
||||||
|
"alert_notification_permission_denied_description": "Por favor reactívaas no navegador",
|
||||||
|
"alert_notification_ios_install_required_title": "Require instalación iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Preme na icona Compartir e Engadir a Pantalla de Inicio para activar as notificacións en iOS",
|
||||||
|
"notifications_actions_failed_notification": "Non se puido realizar a acción",
|
||||||
|
"publish_dialog_checkbox_markdown": "Dar formato Markdow",
|
||||||
|
"prefs_notifications_web_push_title": "Notificacións en segundo plano",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Recíbense notificacións incluso se a app web non está en execución (vía Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Recíbense as notificacións cando a app web está en execución (vía WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Desactivadas",
|
||||||
|
"prefs_appearance_theme_title": "Decorado",
|
||||||
|
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||||
|
"prefs_appearance_theme_dark": "Modo escuro",
|
||||||
|
"prefs_appearance_theme_light": "Modo claro",
|
||||||
|
"error_boundary_button_reload_ntfy": "Recargar ntfy",
|
||||||
|
"web_push_subscription_expiring_title": "Vanse pausar as notificacións",
|
||||||
|
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
|
||||||
|
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
|
||||||
|
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,5 +381,28 @@
|
|||||||
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
|
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
|
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
|
||||||
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
|
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi"
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi",
|
||||||
|
"action_bar_unmute_notifications": "Nyalakan notifikasi",
|
||||||
|
"alert_notification_permission_denied_title": "Notifikasi sedang diblokir",
|
||||||
|
"alert_notification_permission_denied_description": "Silakan aktifkan lagi dalam peramban Anda",
|
||||||
|
"alert_notification_ios_install_required_title": "Pemasangan iOS diperlukan",
|
||||||
|
"alert_notification_ios_install_required_description": "Klik ikon Bagikan dan Tambahkan ke Layar Beranda untuk mengaktifkan notifikasi di iOS",
|
||||||
|
"notifications_actions_failed_notification": "Tindakan tidak berhasil",
|
||||||
|
"publish_dialog_checkbox_markdown": "Format sebagai Markdown",
|
||||||
|
"prefs_notifications_web_push_title": "Notifikasi latar belakang",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Notifikasi diterima bahkan ketika aplikasi web tidak berjalan (melalui Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Notifikasi diterima ketika aplikasi web berjalan (melalui WebSocket)",
|
||||||
|
"prefs_appearance_theme_title": "Tema",
|
||||||
|
"error_boundary_button_reload_ntfy": "Muat ulang ntfy",
|
||||||
|
"action_bar_mute_notifications": "Matikan notifikasi",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Notifikasi dari server lain tidak akan diterima ketika aplikasi web tidak buka",
|
||||||
|
"prefs_notifications_web_push_enabled": "Diaktifkan untuk {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Dinonaktifkan",
|
||||||
|
"prefs_appearance_theme_dark": "Mode gelap",
|
||||||
|
"prefs_appearance_theme_system": "Sistem (bawaan)",
|
||||||
|
"prefs_appearance_theme_light": "Mode terang",
|
||||||
|
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
||||||
|
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
|
||||||
|
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
|
||||||
|
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
"alert_grant_title": "Oznámenia sú vypnuté",
|
"alert_grant_title": "Oznámenia sú vypnuté",
|
||||||
"alert_grant_button": "Prideliť teraz",
|
"alert_grant_button": "Prideliť teraz",
|
||||||
"alert_not_supported_title": "Oznámenia nie sú podporované",
|
"alert_not_supported_title": "Oznámenia nie sú podporované",
|
||||||
"alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované.",
|
"alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované",
|
||||||
"notifications_attachment_copy_url_title": "Kopírovať URL adresu prílohy do schránky",
|
"notifications_attachment_copy_url_title": "Kopírovať URL adresu prílohy do schránky",
|
||||||
"notifications_attachment_copy_url_button": "Kopírovať adresu URL",
|
"notifications_attachment_copy_url_button": "Kopírovať adresu URL",
|
||||||
"notifications_attachment_open_title": "Prejsť na {{url}}",
|
"notifications_attachment_open_title": "Prejsť na {{url}}",
|
||||||
@@ -380,5 +380,31 @@
|
|||||||
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne <strong>vymažte aspoň {{count}} rezervácií</strong>. Rezervácie môžete odstrániť v <Link>Nastaveniach</Link>.",
|
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne <strong>vymažte aspoň {{count}} rezervácií</strong>. Rezervácie môžete odstrániť v <Link>Nastaveniach</Link>.",
|
||||||
"prefs_users_dialog_title_add": "Pridať používateľa",
|
"prefs_users_dialog_title_add": "Pridať používateľa",
|
||||||
"account_tokens_dialog_button_create": "Vytvoriť token",
|
"account_tokens_dialog_button_create": "Vytvoriť token",
|
||||||
"account_tokens_table_create_token_button": "Vytvoriť prístupový token"
|
"account_tokens_table_create_token_button": "Vytvoriť prístupový token",
|
||||||
|
"action_bar_mute_notifications": "Stlmiť oznámenia",
|
||||||
|
"action_bar_unmute_notifications": "Zrušiť stlmenie oznámení",
|
||||||
|
"alert_notification_permission_required_description": "Udeliť povolenie prehliadaču na zobrazovanie oznámení na ploche",
|
||||||
|
"alert_notification_permission_required_button": "Udeliť teraz",
|
||||||
|
"alert_notification_permission_denied_title": "Oznámenia sú zablokované",
|
||||||
|
"alert_notification_permission_denied_description": "Opätovne ich povoľte vo svojom prehliadači",
|
||||||
|
"alert_notification_ios_install_required_title": "Vyžaduje sa inštalácia iOS",
|
||||||
|
"notifications_actions_failed_notification": "Neúspešná akcia",
|
||||||
|
"publish_dialog_checkbox_markdown": "Formátovať ako Markdown",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Oznámenia z iných serverov sa nebudú prijímať, keď webová aplikácia nie je otvorená",
|
||||||
|
"prefs_notifications_web_push_title": "Oznámenia na pozadí",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Oznámenia sa prijímajú, aj keď webová aplikácia nie je spustená (prostredníctvom Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Oznámenia sa prijímajú, keď je webová aplikácia spustená (cez WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Povolené pre {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Zakázané",
|
||||||
|
"prefs_appearance_theme_title": "Téma",
|
||||||
|
"prefs_appearance_theme_system": "Systémové (predvolené)",
|
||||||
|
"prefs_appearance_theme_dark": "Tmavý režim",
|
||||||
|
"prefs_appearance_theme_light": "Svetlý režim",
|
||||||
|
"error_boundary_button_reload_ntfy": "Obnoviť ntfy",
|
||||||
|
"web_push_subscription_expiring_title": "Oznámenia budú pozastavené",
|
||||||
|
"web_push_subscription_expiring_body": "Ak chcete pokračovať v prijímaní upozornení, otvorte ntfy",
|
||||||
|
"web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera",
|
||||||
|
"web_push_unknown_notification_body": "Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie",
|
||||||
|
"alert_notification_permission_required_title": "Oznámenia sú vypnuté",
|
||||||
|
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
"nav_button_documentation": "Belgelendirme",
|
"nav_button_documentation": "Belgelendirme",
|
||||||
"nav_button_publish_message": "Bildirim yayınla",
|
"nav_button_publish_message": "Bildirim yayınla",
|
||||||
"alert_notification_permission_required_title": "Bildirimler devre dışı",
|
"alert_notification_permission_required_title": "Bildirimler devre dışı",
|
||||||
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.",
|
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin",
|
||||||
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor.",
|
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor",
|
||||||
"notifications_copied_to_clipboard": "Panoya kopyalandı",
|
"notifications_copied_to_clipboard": "Panoya kopyalandı",
|
||||||
"notifications_tags": "Etiketler",
|
"notifications_tags": "Etiketler",
|
||||||
"notifications_attachment_copy_url_title": "Ek URL'sini panoya kopyala",
|
"notifications_attachment_copy_url_title": "Ek URL'sini panoya kopyala",
|
||||||
@@ -380,5 +380,28 @@
|
|||||||
"account_basics_phone_numbers_dialog_code_label": "Doğrulama kodu",
|
"account_basics_phone_numbers_dialog_code_label": "Doğrulama kodu",
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "örn. 123456",
|
"account_basics_phone_numbers_dialog_code_placeholder": "örn. 123456",
|
||||||
"account_usage_calls_title": "Yapılan telefon aramaları",
|
"account_usage_calls_title": "Yapılan telefon aramaları",
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok"
|
"account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok",
|
||||||
|
"action_bar_mute_notifications": "Bildirimleri sessize al",
|
||||||
|
"action_bar_unmute_notifications": "Bildirimlerin sesini aç",
|
||||||
|
"alert_notification_permission_denied_title": "Bildirimler engellendi",
|
||||||
|
"alert_notification_permission_denied_description": "Lütfen tarayıcınızda yeniden etkinleştirin",
|
||||||
|
"alert_notification_ios_install_required_title": "iOS kurulumu gerekli",
|
||||||
|
"alert_notification_ios_install_required_description": "iOS'ta bildirimleri etkinleştirmek için Paylaş simgesine ve Ana Ekrana Ekle'ye tıklayın",
|
||||||
|
"notifications_actions_failed_notification": "Başarısız eylem",
|
||||||
|
"publish_dialog_checkbox_markdown": "Markdown olarak biçimlendir",
|
||||||
|
"prefs_notifications_web_push_title": "Arka plan bildirimleri",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Web uygulaması çalışmadığında bile bildirimler alınır (Web Push aracılığıyla)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Web uygulaması çalışırken bildirim alınır (WebSocket aracılığıyla)",
|
||||||
|
"prefs_notifications_web_push_enabled": "{{server}} için etkinleştirildi",
|
||||||
|
"prefs_notifications_web_push_disabled": "Devre dışı",
|
||||||
|
"prefs_appearance_theme_title": "Tema",
|
||||||
|
"prefs_appearance_theme_system": "Sistem (öntanımlı)",
|
||||||
|
"prefs_appearance_theme_dark": "Koyu mod",
|
||||||
|
"prefs_appearance_theme_light": "Açık mod",
|
||||||
|
"error_boundary_button_reload_ntfy": "ntfy'yi yeniden yükle",
|
||||||
|
"web_push_subscription_expiring_title": "Bildirimler duraklatılacak",
|
||||||
|
"web_push_subscription_expiring_body": "Bildirimleri almaya devam etmek için ntfy'yi açın",
|
||||||
|
"web_push_unknown_notification_title": "Sunucudan bilinmeyen bildirim alındı",
|
||||||
|
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user