Compare commits

...

12 Commits

Author SHA1 Message Date
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
9 changed files with 1858 additions and 1330 deletions

View File

@@ -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{

View File

@@ -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")

File diff suppressed because one or more lines are too long

View File

@@ -1605,8 +1605,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* 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), * 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) [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) * 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)) * `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)) * 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)

View File

@@ -280,6 +280,7 @@ func NewConfig() *Config {
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BuildVersion: "", BuildVersion: "",
BuildDate: "", 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) 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()

View File

@@ -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)

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") 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

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"]) 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"