Compare commits

...

18 Commits

Author SHA1 Message Date
binwiederhier
4b474a89b7 Docs 2026-01-19 18:29:45 -05:00
binwiederhier
5ba1c71140 Fix grouping issue with sequence ID 2026-01-18 21:30:12 -05:00
binwiederhier
de81865c27 Bump deps 2026-01-18 20:11:48 -05:00
binwiederhier
ed9c1bcb78 Wording change 2026-01-18 19:46:14 -05:00
binwiederhier
190d12cd54 Release banner 2026-01-18 19:41:34 -05:00
Philipp C. Heckel
63bf82e915 Merge pull request #1556 from binwiederhier/cancel-scheduled
Updated/cancel scheduled messages
2026-01-18 19:38:52 -05:00
binwiederhier
014b7355c5 Re-org 2026-01-18 19:24:01 -05:00
binwiederhier
602f201bae Derp 2026-01-18 19:15:10 -05:00
binwiederhier
2739d8a325 Re-organize docs 2026-01-18 19:09:14 -05:00
binwiederhier
b8e01fde33 Docs 2026-01-18 16:22:06 -05:00
binwiederhier
9ecf21c65a Docs 2026-01-18 16:16:04 -05:00
binwiederhier
ac9cfbfaf4 Delete attachments 2026-01-18 16:04:42 -05:00
binwiederhier
c23d201186 Updated/cancel scheduled messages 2026-01-18 15:50:40 -05:00
binwiederhier
86157fc7f6 Lint 2026-01-18 11:14:07 -05:00
binwiederhier
279c164bf5 Fix build 2026-01-18 11:13:56 -05:00
binwiederhier
743b00e59c Merge branch 'main' of github.com:binwiederhier/ntfy 2026-01-18 10:57:10 -05:00
binwiederhier
eddf654b96 Last fixes 2026-01-18 10:56:11 -05:00
Philipp C. Heckel
8deb2df88d Update Windows support details in releases.md 2026-01-17 20:26:37 -05:00
15 changed files with 1970 additions and 1402 deletions

View File

@@ -3,11 +3,12 @@ package cmd
import (
"fmt"
"os"
"regexp"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"os"
"regexp"
)
const (
@@ -15,6 +16,12 @@ const (
categoryServer = "Server commands"
)
// Build metadata keys for app.Metadata
const (
MetadataKeyCommit = "commit"
MetadataKeyDate = "date"
)
var commands = make([]*cli.Command, 0)
var flagsDefault = []cli.Flag{

View File

@@ -128,12 +128,6 @@ Examples:
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 {
if c.NArg() > 0 {
return errors.New("no arguments expected, see 'ntfy serve --help' for help")

View File

@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.15.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
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -116,7 +116,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +124,7 @@ Manually installing the .deb file:
=== "armv6"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +132,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +140,7 @@ Manually installing the .deb file:
=== "arm64"
```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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,28 +150,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```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 start ntfy
```
=== "armv6"
```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 start ntfy
```
=== "armv7/armhf"
```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 start ntfy
```
=== "arm64"
```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 start ntfy
```
@@ -201,18 +201,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
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`).
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).
```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
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
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.16.0_darwin_all.tar.gz
sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/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
```
@@ -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.
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%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`

File diff suppressed because one or more lines are too long

View File

@@ -6,12 +6,34 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| 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 iOS app | v1.3 | Nov 26, 2023 |
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
Released January 6, 2026
@@ -1599,16 +1621,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## 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)
**Features:**

View File

@@ -280,6 +280,7 @@ func NewConfig() *Config {
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BuildVersion: "",
BuildDate: "",
BuildCommit: "",
}
}

View File

@@ -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)
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 = `
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
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
FROM messages
WHERE mid = ?
@@ -607,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
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 {
c.mu.Lock()
defer c.mu.Unlock()

View File

@@ -703,6 +703,79 @@ func testSender(t *testing.T, c *messageCache) {
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) {
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)

View File

@@ -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")
}
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")
if err := s.messageCache.AddMessage(m); err != nil {
return nil, err
@@ -958,6 +969,19 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
if s.config.WebPushPublicKey != "" {
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
if err := s.messageCache.AddMessage(m); err != nil {
return err

View File

@@ -3495,6 +3495,162 @@ func TestServer_ClearMessage_WithFirebase(t *testing.T) {
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 {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"

6
web/package-lock.json generated
View File

@@ -3823,9 +3823,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001764",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
"version": "1.0.30001765",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
"integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
"dev": true,
"funding": [
{

View File

@@ -4,7 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
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 {
EVENT_MESSAGE,
@@ -38,6 +38,13 @@ const handlePushMessage = async (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)
const sequenceId = message.sequence_id || message.id;
if (sequenceId) {
@@ -65,10 +72,11 @@ const handlePushMessage = async (data) => {
await self.registration.showNotification(
...toNotificationParams({
subscriptionId,
message,
defaultTitle: message.topic,
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();
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
const sequenceId = message.sequence_id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).delete();
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Close browser notification with matching tag (scoped by topic)
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {
@@ -108,18 +121,23 @@ const handlePushMessageClear = async (data) => {
const db = await dbAsync();
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)
const sequenceId = message.sequence_id;
if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Close browser notification with matching tag (scoped by topic)
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
// Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, {

View File

@@ -1,5 +1,5 @@
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
import { toNotificationParams } from "./notificationUtils";
import { notificationTag, toNotificationParams } from "./notificationUtils";
import prefs from "./Prefs";
import routes from "../components/routes";
@@ -23,21 +23,23 @@ class Notifier {
const registration = await this.serviceWorkerRegistration();
await registration.showNotification(
...toNotificationParams({
subscriptionId: subscription.id,
message: notification,
defaultTitle,
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()) {
return;
}
try {
const tag = notification.sequence_id || notification.id;
console.log(`[Notifier] Cancelling notification with ${tag}`);
const sequenceId = notification.sequence_id || notification.id;
const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
console.log(`[Notifier] Cancelling notification with tag ${tag}`);
const registration = await this.serviceWorkerRegistration();
const notifications = await registration.getNotifications({ tag });
notifications.forEach((n) => n.close());

View File

@@ -50,8 +50,16 @@ export const isImage = (attachment) => {
export const icon = "/static/images/ntfy.png";
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 sequenceId = message.sequence_id || message.id;
const tag = notificationTag(baseUrl, topic, sequenceId);
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
return [
@@ -62,7 +70,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
icon,
image,
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,
silent: false,
// This is used by the notification onclick event

View File

@@ -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
// - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived()
@@ -59,20 +59,20 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
await notifier.cancel(subscription, notification);
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
await notifier.cancel(subscription, notification);
} else {
// Regular message: delete existing and add new
const sequenceId = notification.sequence_id || notification.id;
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) {
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) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
await handleNotification(subscription, message);
}
};