Compare commits
18 Commits
update-ava
...
v2.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b474a89b7 | ||
|
|
5ba1c71140 | ||
|
|
de81865c27 | ||
|
|
ed9c1bcb78 | ||
|
|
190d12cd54 | ||
|
|
63bf82e915 | ||
|
|
014b7355c5 | ||
|
|
602f201bae | ||
|
|
2739d8a325 | ||
|
|
b8e01fde33 | ||
|
|
9ecf21c65a | ||
|
|
ac9cfbfaf4 | ||
|
|
c23d201186 | ||
|
|
86157fc7f6 | ||
|
|
279c164bf5 | ||
|
|
743b00e59c | ||
|
|
eddf654b96 | ||
|
|
8deb2df88d |
11
cmd/app.go
11
cmd/app.go
@@ -3,11 +3,12 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,6 +16,12 @@ const (
|
|||||||
categoryServer = "Server commands"
|
categoryServer = "Server commands"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build metadata keys for app.Metadata
|
||||||
|
const (
|
||||||
|
MetadataKeyCommit = "commit"
|
||||||
|
MetadataKeyDate = "date"
|
||||||
|
)
|
||||||
|
|
||||||
var commands = make([]*cli.Command, 0)
|
var commands = make([]*cli.Command, 0)
|
||||||
|
|
||||||
var flagsDefault = []cli.Flag{
|
var flagsDefault = []cli.Flag{
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ Examples:
|
|||||||
ntfy serve --listen-http :8080 # Starts server with alternate port`,
|
ntfy serve --listen-http :8080 # Starts server with alternate port`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// App metadata fields used to pass from
|
|
||||||
const (
|
|
||||||
MetadataKeyCommit = "commit"
|
|
||||||
MetadataKeyDate = "date"
|
|
||||||
)
|
|
||||||
|
|
||||||
func execServe(c *cli.Context) error {
|
func execServe(c *cli.Context) error {
|
||||||
if c.NArg() > 0 {
|
if c.NArg() > 0 {
|
||||||
return errors.New("no arguments expected, see 'ntfy serve --help' for help")
|
return errors.New("no arguments expected, see 'ntfy serve --help' for help")
|
||||||
|
|||||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.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.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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
|
||||||
@@ -124,7 +124,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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
|
||||||
@@ -132,7 +132,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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
|
||||||
@@ -140,7 +140,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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
|
||||||
@@ -150,28 +150,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.15.0/ntfy_2.15.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -201,18 +201,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.15.0/ntfy_2.15.0_darwin_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz > ntfy_2.16.0_darwin_all.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.16.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ brew install ntfy
|
|||||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||||
To install, you can either
|
To install, you can either
|
||||||
|
|
||||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
|
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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%`.
|
||||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||||
|
|
||||||
|
|||||||
2855
docs/publish.md
2855
docs/publish.md
File diff suppressed because one or more lines are too long
@@ -6,12 +6,34 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
| Component | Version | Release date |
|
| Component | Version | Release date |
|
||||||
|------------------|---------|--------------|
|
|------------------|---------|--------------|
|
||||||
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
| ntfy server | v2.16.0 | Jan 19, 2026 |
|
||||||
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
|
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
|
||||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||||
|
|
||||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||||
|
|
||||||
|
## ntfy server v2.16.0
|
||||||
|
Released January 19, 2026
|
||||||
|
|
||||||
|
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
|
||||||
|
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
|
||||||
|
|
||||||
|
This one is very exciting, as it brings a lot of highly requested features to ntfy.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||||
|
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||||
|
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||||
|
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||||
|
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||||
|
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||||
|
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||||
|
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||||
|
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||||
|
thanks to [@wtf911](https://github.com/wtf911))
|
||||||
|
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||||
|
|
||||||
## ntfy Android app v1.21.1
|
## ntfy Android app v1.21.1
|
||||||
Released January 6, 2026
|
Released January 6, 2026
|
||||||
|
|
||||||
@@ -1599,16 +1621,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy server v2.16.x (UNRELEASED)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
|
||||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
|
||||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
|
||||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911))
|
|
||||||
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
|
||||||
|
|
||||||
### ntfy Android app v1.22.x (UNRELEASED)
|
### ntfy Android app v1.22.x (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ func NewConfig() *Config {
|
|||||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||||
BuildVersion: "",
|
BuildVersion: "",
|
||||||
BuildDate: "",
|
BuildDate: "",
|
||||||
|
BuildCommit: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ const (
|
|||||||
INSERT INTO messages (mid, sequence_id, time, event, 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)
|
INSERT INTO messages (mid, sequence_id, time, event, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||||
selectMessagesByIDQuery = `
|
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, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, sequence_id, time, event, 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
|
FROM messages
|
||||||
WHERE mid = ?
|
WHERE mid = ?
|
||||||
@@ -607,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
|
||||||
|
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
|
||||||
|
func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
tx, err := c.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
// First, get the message IDs of scheduled messages to be deleted
|
||||||
|
rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close() // Close rows before executing delete in same transaction
|
||||||
|
// Then delete the messages
|
||||||
|
if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
@@ -703,6 +703,79 @@ func testSender(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) {
|
||||||
|
testDeleteScheduledBySequenceID(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) {
|
||||||
|
testDeleteScheduledBySequenceID(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) {
|
||||||
|
// Create a scheduled (unpublished) message
|
||||||
|
scheduledMsg := newDefaultMessage("mytopic", "scheduled message")
|
||||||
|
scheduledMsg.ID = "scheduled1"
|
||||||
|
scheduledMsg.SequenceID = "seq123"
|
||||||
|
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
|
||||||
|
require.Nil(t, c.AddMessage(scheduledMsg))
|
||||||
|
|
||||||
|
// Create a published message with different sequence ID
|
||||||
|
publishedMsg := newDefaultMessage("mytopic", "published message")
|
||||||
|
publishedMsg.ID = "published1"
|
||||||
|
publishedMsg.SequenceID = "seq456"
|
||||||
|
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
|
||||||
|
require.Nil(t, c.AddMessage(publishedMsg))
|
||||||
|
|
||||||
|
// Create a scheduled message in a different topic
|
||||||
|
otherTopicMsg := newDefaultMessage("othertopic", "other scheduled")
|
||||||
|
otherTopicMsg.ID = "other1"
|
||||||
|
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
|
||||||
|
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
|
||||||
|
require.Nil(t, c.AddMessage(otherTopicMsg))
|
||||||
|
|
||||||
|
// Verify all messages exist (including scheduled)
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
|
||||||
|
messages, err = c.Messages("othertopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
|
||||||
|
// Delete scheduled message by sequence ID and verify returned IDs
|
||||||
|
deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(deletedIDs))
|
||||||
|
require.Equal(t, "scheduled1", deletedIDs[0])
|
||||||
|
|
||||||
|
// Verify scheduled message is deleted
|
||||||
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "published message", messages[0].Message)
|
||||||
|
|
||||||
|
// Verify other topic's message still exists (topic-scoped deletion)
|
||||||
|
messages, err = c.Messages("othertopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "other scheduled", messages[0].Message)
|
||||||
|
|
||||||
|
// Deleting non-existent sequence ID should return empty list
|
||||||
|
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, deletedIDs)
|
||||||
|
|
||||||
|
// Deleting published message should not affect it (only deletes unpublished)
|
||||||
|
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, deletedIDs)
|
||||||
|
|
||||||
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "published message", messages[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
@@ -863,6 +863,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
||||||
}
|
}
|
||||||
if cache {
|
if cache {
|
||||||
|
// Delete any existing scheduled message with the same sequence ID
|
||||||
|
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Delete attachment files for deleted scheduled messages
|
||||||
|
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||||
|
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||||
|
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
|
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -958,6 +969,19 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
|
|||||||
if s.config.WebPushPublicKey != "" {
|
if s.config.WebPushPublicKey != "" {
|
||||||
go s.publishToWebPushEndpoints(v, m)
|
go s.publishToWebPushEndpoints(v, m)
|
||||||
}
|
}
|
||||||
|
if event == messageDeleteEvent {
|
||||||
|
// Delete any existing scheduled message with the same sequence ID
|
||||||
|
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete attachment files for deleted scheduled messages
|
||||||
|
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||||
|
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||||
|
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Add to message cache
|
// Add to message cache
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3495,6 +3495,162 @@ func TestServer_ClearMessage_WithFirebase(t *testing.T) {
|
|||||||
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
|
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_UpdateScheduledMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message (future delivery)
|
||||||
|
response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg1 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "sched-seq", msg1.SequenceID)
|
||||||
|
require.Equal(t, "original scheduled message", msg1.Message)
|
||||||
|
|
||||||
|
// Verify scheduled message exists
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "original scheduled message", messages[0].Message)
|
||||||
|
|
||||||
|
// Update the scheduled message (same sequence ID, new content)
|
||||||
|
response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg2 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "sched-seq", msg2.SequenceID)
|
||||||
|
require.Equal(t, "updated scheduled message", msg2.Message)
|
||||||
|
require.NotEqual(t, msg1.ID, msg2.ID)
|
||||||
|
|
||||||
|
// Verify only the updated message exists (old scheduled was deleted)
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "updated scheduled message", messages[0].Message)
|
||||||
|
require.Equal(t, msg2.ID, messages[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message (future delivery)
|
||||||
|
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||||
|
|
||||||
|
// Verify scheduled message exists
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||||
|
|
||||||
|
// Delete the scheduled message
|
||||||
|
response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
deleteMsg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||||
|
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||||
|
|
||||||
|
// Verify scheduled message was deleted, only delete event remains
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "message_delete", messages[0].Event)
|
||||||
|
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish scheduled messages with same sequence ID in different topics
|
||||||
|
response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
// Update scheduled message in topic1 only
|
||||||
|
response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
// Verify topic1 has only the updated message
|
||||||
|
response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "topic1 updated", messages[0].Message)
|
||||||
|
|
||||||
|
// Verify topic2 still has its original scheduled message (not affected)
|
||||||
|
response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "topic2 scheduled", messages[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message with an attachment
|
||||||
|
content := util.RandomString(5000) // > 4096 to trigger attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg1 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "attach-seq", msg1.SequenceID)
|
||||||
|
require.NotNil(t, msg1.Attachment)
|
||||||
|
|
||||||
|
// Verify attachment file exists
|
||||||
|
attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID)
|
||||||
|
require.FileExists(t, attachmentFile1)
|
||||||
|
|
||||||
|
// Update the scheduled message with a new attachment
|
||||||
|
newContent := util.RandomString(5000)
|
||||||
|
response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg2 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "attach-seq", msg2.SequenceID)
|
||||||
|
require.NotEqual(t, msg1.ID, msg2.ID)
|
||||||
|
|
||||||
|
// Verify old attachment file was deleted
|
||||||
|
require.NoFileExists(t, attachmentFile1)
|
||||||
|
|
||||||
|
// Verify new attachment file exists
|
||||||
|
attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID)
|
||||||
|
require.FileExists(t, attachmentFile2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message with an attachment
|
||||||
|
content := util.RandomString(5000) // > 4096 to trigger attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "delete-attach-seq", msg.SequenceID)
|
||||||
|
require.NotNil(t, msg.Attachment)
|
||||||
|
|
||||||
|
// Verify attachment file exists
|
||||||
|
attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||||
|
require.FileExists(t, attachmentFile)
|
||||||
|
|
||||||
|
// Delete the scheduled message
|
||||||
|
response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
deleteMsg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||||
|
|
||||||
|
// Verify attachment file was deleted
|
||||||
|
require.NoFileExists(t, attachmentFile)
|
||||||
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
|
|||||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -3823,9 +3823,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001764",
|
"version": "1.0.30001765",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
|
||||||
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
|
"integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
|
|||||||
import { NetworkFirst } from "workbox-strategies";
|
import { NetworkFirst } from "workbox-strategies";
|
||||||
import { clientsClaim } from "workbox-core";
|
import { clientsClaim } from "workbox-core";
|
||||||
import { dbAsync } from "../src/app/db";
|
import { dbAsync } from "../src/app/db";
|
||||||
import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
|
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
||||||
import initI18n from "../src/app/i18n";
|
import initI18n from "../src/app/i18n";
|
||||||
import {
|
import {
|
||||||
EVENT_MESSAGE,
|
EVENT_MESSAGE,
|
||||||
@@ -38,6 +38,13 @@ const handlePushMessage = async (data) => {
|
|||||||
|
|
||||||
console.log("[ServiceWorker] Message received", data);
|
console.log("[ServiceWorker] Message received", data);
|
||||||
|
|
||||||
|
// Look up subscription for baseUrl and topic
|
||||||
|
const subscription = await db.subscriptions.get(subscriptionId);
|
||||||
|
if (!subscription) {
|
||||||
|
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete existing notification with same sequence ID (if any)
|
// Delete existing notification with same sequence ID (if any)
|
||||||
const sequenceId = message.sequence_id || message.id;
|
const sequenceId = message.sequence_id || message.id;
|
||||||
if (sequenceId) {
|
if (sequenceId) {
|
||||||
@@ -65,10 +72,11 @@ const handlePushMessage = async (data) => {
|
|||||||
|
|
||||||
await self.registration.showNotification(
|
await self.registration.showNotification(
|
||||||
...toNotificationParams({
|
...toNotificationParams({
|
||||||
subscriptionId,
|
|
||||||
message,
|
message,
|
||||||
defaultTitle: message.topic,
|
defaultTitle: message.topic,
|
||||||
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
||||||
|
baseUrl: subscription.baseUrl,
|
||||||
|
topic: subscription.topic,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -81,18 +89,23 @@ const handlePushMessageDelete = async (data) => {
|
|||||||
const db = await dbAsync();
|
const db = await dbAsync();
|
||||||
console.log("[ServiceWorker] Deleting notification sequence", data);
|
console.log("[ServiceWorker] Deleting notification sequence", data);
|
||||||
|
|
||||||
|
// Look up subscription for baseUrl and topic
|
||||||
|
const subscription = await db.subscriptions.get(subscriptionId);
|
||||||
|
if (!subscription) {
|
||||||
|
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete notification with the same sequence_id
|
// Delete notification with the same sequence_id
|
||||||
const sequenceId = message.sequence_id;
|
const sequenceId = message.sequence_id;
|
||||||
if (sequenceId) {
|
if (sequenceId) {
|
||||||
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close browser notification with matching tag
|
// Close browser notification with matching tag (scoped by topic)
|
||||||
const tag = message.sequence_id || message.id;
|
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
|
||||||
if (tag) {
|
const notifications = await self.registration.getNotifications({ tag });
|
||||||
const notifications = await self.registration.getNotifications({ tag });
|
notifications.forEach((notification) => notification.close());
|
||||||
notifications.forEach((notification) => notification.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update subscription last message id (for ?since=... queries)
|
// Update subscription last message id (for ?since=... queries)
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
@@ -108,18 +121,23 @@ const handlePushMessageClear = async (data) => {
|
|||||||
const db = await dbAsync();
|
const db = await dbAsync();
|
||||||
console.log("[ServiceWorker] Marking notification as read", data);
|
console.log("[ServiceWorker] Marking notification as read", data);
|
||||||
|
|
||||||
|
// Look up subscription for baseUrl and topic
|
||||||
|
const subscription = await db.subscriptions.get(subscriptionId);
|
||||||
|
if (!subscription) {
|
||||||
|
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark notification as read (set new = 0)
|
// Mark notification as read (set new = 0)
|
||||||
const sequenceId = message.sequence_id;
|
const sequenceId = message.sequence_id;
|
||||||
if (sequenceId) {
|
if (sequenceId) {
|
||||||
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
|
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close browser notification with matching tag
|
// Close browser notification with matching tag (scoped by topic)
|
||||||
const tag = message.sequence_id || message.id;
|
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
|
||||||
if (tag) {
|
const notifications = await self.registration.getNotifications({ tag });
|
||||||
const notifications = await self.registration.getNotifications({ tag });
|
notifications.forEach((notification) => notification.close());
|
||||||
notifications.forEach((notification) => notification.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update subscription last message id (for ?since=... queries)
|
// Update subscription last message id (for ?since=... queries)
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
|
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
|
||||||
import { toNotificationParams } from "./notificationUtils";
|
import { notificationTag, toNotificationParams } from "./notificationUtils";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import routes from "../components/routes";
|
import routes from "../components/routes";
|
||||||
|
|
||||||
@@ -23,21 +23,23 @@ class Notifier {
|
|||||||
const registration = await this.serviceWorkerRegistration();
|
const registration = await this.serviceWorkerRegistration();
|
||||||
await registration.showNotification(
|
await registration.showNotification(
|
||||||
...toNotificationParams({
|
...toNotificationParams({
|
||||||
subscriptionId: subscription.id,
|
|
||||||
message: notification,
|
message: notification,
|
||||||
defaultTitle,
|
defaultTitle,
|
||||||
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
|
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
|
||||||
|
baseUrl: subscription.baseUrl,
|
||||||
|
topic: subscription.topic,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(notification) {
|
async cancel(subscription, notification) {
|
||||||
if (!this.supported()) {
|
if (!this.supported()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tag = notification.sequence_id || notification.id;
|
const sequenceId = notification.sequence_id || notification.id;
|
||||||
console.log(`[Notifier] Cancelling notification with ${tag}`);
|
const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
|
||||||
|
console.log(`[Notifier] Cancelling notification with tag ${tag}`);
|
||||||
const registration = await this.serviceWorkerRegistration();
|
const registration = await this.serviceWorkerRegistration();
|
||||||
const notifications = await registration.getNotifications({ tag });
|
const notifications = await registration.getNotifications({ tag });
|
||||||
notifications.forEach((n) => n.close());
|
notifications.forEach((n) => n.close());
|
||||||
|
|||||||
@@ -50,8 +50,16 @@ export const isImage = (attachment) => {
|
|||||||
export const icon = "/static/images/ntfy.png";
|
export const icon = "/static/images/ntfy.png";
|
||||||
export const badge = "/static/images/mask-icon.svg";
|
export const badge = "/static/images/mask-icon.svg";
|
||||||
|
|
||||||
export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
/**
|
||||||
|
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
|
||||||
|
* This ensures notifications from different topics with the same sequence ID don't collide.
|
||||||
|
*/
|
||||||
|
export const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`;
|
||||||
|
|
||||||
|
export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => {
|
||||||
const image = isImage(message.attachment) ? message.attachment.url : undefined;
|
const image = isImage(message.attachment) ? message.attachment.url : undefined;
|
||||||
|
const sequenceId = message.sequence_id || message.id;
|
||||||
|
const tag = notificationTag(baseUrl, topic, sequenceId);
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
|
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
|
||||||
return [
|
return [
|
||||||
@@ -62,7 +70,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
|||||||
icon,
|
icon,
|
||||||
image,
|
image,
|
||||||
timestamp: message.time * 1000,
|
timestamp: message.time * 1000,
|
||||||
tag: message.sequence_id || message.id, // Update notification if there is a sequence ID
|
tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions
|
||||||
renotify: true,
|
renotify: true,
|
||||||
silent: false,
|
silent: false,
|
||||||
// This is used by the notification onclick event
|
// This is used by the notification onclick event
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotification = async (subscriptionId, notification) => {
|
const handleNotification = async (subscription, notification) => {
|
||||||
// This logic is (partially) duplicated in
|
// This logic is (partially) duplicated in
|
||||||
// - Android: SubscriberService::onNotificationReceived()
|
// - Android: SubscriberService::onNotificationReceived()
|
||||||
// - Android: FirebaseService::onMessageReceived()
|
// - Android: FirebaseService::onMessageReceived()
|
||||||
@@ -59,20 +59,20 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
|||||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
||||||
|
|
||||||
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
|
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
|
||||||
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
|
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
|
||||||
await notifier.cancel(notification);
|
await notifier.cancel(subscription, notification);
|
||||||
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
|
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
|
||||||
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
|
await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
|
||||||
await notifier.cancel(notification);
|
await notifier.cancel(subscription, notification);
|
||||||
} else {
|
} else {
|
||||||
// Regular message: delete existing and add new
|
// Regular message: delete existing and add new
|
||||||
const sequenceId = notification.sequence_id || notification.id;
|
const sequenceId = notification.sequence_id || notification.id;
|
||||||
if (sequenceId) {
|
if (sequenceId) {
|
||||||
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId);
|
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId);
|
||||||
}
|
}
|
||||||
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
const added = await subscriptionManager.addNotification(subscription.id, notification);
|
||||||
if (added) {
|
if (added) {
|
||||||
await subscriptionManager.notify(subscriptionId, notification);
|
await subscriptionManager.notify(subscription.id, notification);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -89,7 +89,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
|||||||
if (subscription.internal) {
|
if (subscription.internal) {
|
||||||
await handleInternalMessage(message);
|
await handleInternalMessage(message);
|
||||||
} else {
|
} else {
|
||||||
await handleNotification(subscriptionId, message);
|
await handleNotification(subscription, message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user