From 2aae3577cb5238a303eec3c8dde82a2c744181b3 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 17:39:55 -0600 Subject: [PATCH 01/33] add sid, mtime, and deleted to message_cache --- server/message_cache.go | 79 +++++++++++++++++++++++++++--------- server/message_cache_test.go | 29 +++++++++++-- server/types.go | 13 +++++- 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index 03cb4969..2523d66b 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -28,7 +28,9 @@ const ( CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, + sid TEXT NOT NULL, time INT NOT NULL, + mtime INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -48,10 +50,13 @@ const ( user TEXT NOT NULL, content_type TEXT NOT NULL, encoding TEXT NOT NULL, - published INT NOT NULL + published INT NOT NULL, + deleted INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); + CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); @@ -65,56 +70,57 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (mid, sid, time, mtime, 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, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? AND published = 1 - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesSinceIDQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND id > ? AND published = 1 - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND (id > ? OR published = 0) - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesLatestQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE time <= ? AND published = 0 - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + updateMessageDeletedQuery = `UPDATE messages SET deleted = 1 WHERE mid = ?` selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -130,7 +136,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 13 + currentSchemaVersion = 14 createSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -259,6 +265,15 @@ const ( migrate12To13AlterMessagesTableQuery = ` CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); ` + + //13 -> 14 + migrate13To14AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN mtime INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); + CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); + CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); + ` ) var ( @@ -276,6 +291,7 @@ var ( 10: migrateFrom10, 11: migrateFrom11, 12: migrateFrom12, + 13: migrateFrom13, } ) @@ -393,7 +409,9 @@ func (c *messageCache) addMessages(ms []*message) error { } _, err := stmt.Exec( m.ID, + m.SID, m.Time, + m.MTime, m.Expires, m.Topic, m.Message, @@ -414,6 +432,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, + 0, ) if err != nil { return err @@ -692,12 +711,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) { } func readMessage(rows *sql.Rows) (*message, error) { - var timestamp, expires, attachmentSize, attachmentExpires int64 - var priority int - var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + var timestamp, mtimestamp, expires, attachmentSize, attachmentExpires int64 + var priority, deleted int + var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, + &sid, ×tamp, + &mtimestamp, &expires, &topic, &msg, @@ -716,6 +737,7 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, + &deleted, ) if err != nil { return nil, err @@ -746,7 +768,9 @@ func readMessage(rows *sql.Rows) (*message, error) { } return &message{ ID: id, + SID: sid, Time: timestamp, + MTime: mtimestamp, Expires: expires, Event: messageEvent, Topic: topic, @@ -762,6 +786,7 @@ func readMessage(rows *sql.Rows) (*message, error) { User: user, ContentType: contentType, Encoding: encoding, + Deleted: deleted, }, nil } @@ -1016,3 +1041,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error { } return tx.Commit() } + +func migrateFrom13(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 14); err != nil { + return err + } + return tx.Commit() +} diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 778f28fe..6878d78d 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -22,9 +22,11 @@ func TestMemCache_Messages(t *testing.T) { func testCacheMessages(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = 1 + m1.MTime = 1000 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = 2 + m2.MTime = 2000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) @@ -102,10 +104,13 @@ func testCacheMessagesScheduled(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m2 := newDefaultMessage("mytopic", "message 2") m2.Time = time.Now().Add(time.Hour).Unix() + m2.MTime = time.Now().Add(time.Hour).UnixMilli() m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! + m3.MTime = time.Now().Add(time.Minute).UnixMilli() // earlier than m2! m4 := newDefaultMessage("mytopic2", "message 4") m4.Time = time.Now().Add(time.Minute).Unix() + m4.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) require.Nil(t, c.AddMessage(m3)) @@ -179,18 +184,25 @@ func TestMemCache_MessagesSinceID(t *testing.T) { func testCacheMessagesSinceID(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m1.Time = 100 + m1.MTime = 100000 m2 := newDefaultMessage("mytopic", "message 2") m2.Time = 200 + m2.MTime = 200000 m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 + m3.MTime = time.Now().Add(time.Hour).UnixMilli() // Scheduled, in the future, later than m7 and m5 m4 := newDefaultMessage("mytopic", "message 4") m4.Time = 400 + m4.MTime = 400000 m5 := newDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 + m5.MTime = time.Now().Add(time.Minute).UnixMilli() // Scheduled, in the future, later than m7 m6 := newDefaultMessage("mytopic", "message 6") m6.Time = 600 + m6.MTime = 600000 m7 := newDefaultMessage("mytopic", "message 7") m7.Time = 700 + m7.MTime = 700000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) @@ -251,14 +263,17 @@ func testCachePrune(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = now - 10 + m1.MTime = (now - 10) * 1000 m1.Expires = now - 5 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = now - 5 + m2.MTime = (now - 5) * 1000 m2.Expires = now + 5 // In the future m3 := newDefaultMessage("another_topic", "and another one") m3.Time = now - 12 + m3.MTime = (now - 12) * 1000 m3.Expires = now - 2 require.Nil(t, c.AddMessage(m1)) @@ -297,6 +312,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" + m.SID = "m1" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "flower.jpg", @@ -310,6 +326,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires2 := time.Now().Add(2 * time.Hour).Unix() // Future m = newDefaultMessage("mytopic", "sending you a car") m.ID = "m2" + m.SID = "m2" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "car.jpg", @@ -323,6 +340,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires3 := time.Now().Add(1 * time.Hour).Unix() // Future m = newDefaultMessage("another-topic", "sending you another car") m.ID = "m3" + m.SID = "m3" m.User = "u_BAsbaAa" m.Sender = netip.MustParseAddr("5.6.7.8") m.Attachment = &attachment{ @@ -378,11 +396,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) { func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" + m.SID = "m1" m.Expires = time.Now().Add(time.Hour).Unix() require.Nil(t, c.AddMessage(m)) m = newDefaultMessage("mytopic", "message with attachment") m.ID = "m2" + m.SID = "m2" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -395,6 +415,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic", "message with external attachment") m.ID = "m3" + m.SID = "m3" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -406,6 +427,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic2", "message with expired attachment") m.ID = "m4" + m.SID = "m4" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "expired-car.jpg", @@ -502,6 +524,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) { // Add delayed message delayedMessage := newDefaultMessage("mytopic", "some delayed message") delayedMessage.Time = time.Now().Add(time.Minute).Unix() + delayedMessage.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(delayedMessage)) // 10, not 11! diff --git a/server/types.go b/server/types.go index 65492e46..9e65045f 100644 --- a/server/types.go +++ b/server/types.go @@ -25,7 +25,9 @@ const ( // message represents a message published to a topic type message struct { ID string `json:"id"` // Random message ID + SID string `json:"sid"` // Message sequence ID for updating message contents Time int64 `json:"time"` // Unix time in seconds + MTime int64 `json:"mtime"` // Unix time in milliseconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` @@ -42,13 +44,16 @@ type message struct { Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments + Deleted int `json:"deleted,omitempty"` } func (m *message) Context() log.Context { fields := map[string]any{ "topic": m.Topic, "message_id": m.ID, + "message_sid": m.SID, "message_time": m.Time, + "message_mtime": m.MTime, "message_event": m.Event, "message_body_size": len(m.Message), } @@ -92,6 +97,7 @@ func newAction() *action { // publishMessage is used as input when publishing as JSON type publishMessage struct { Topic string `json:"topic"` + SID string `json:"sid"` Title string `json:"title"` Message string `json:"message"` Priority int `json:"priority"` @@ -117,6 +123,7 @@ func newMessage(event, topic, msg string) *message { return &message{ ID: util.RandomString(messageIDLength), Time: time.Now().Unix(), + MTime: time.Now().UnixMilli(), Event: event, Topic: topic, Message: msg, @@ -155,7 +162,11 @@ type sinceMarker struct { } func newSinceTime(timestamp int64) sinceMarker { - return sinceMarker{time.Unix(timestamp, 0), ""} + return newSinceMTime(timestamp * 1000) +} + +func newSinceMTime(mtimestamp int64) sinceMarker { + return sinceMarker{time.UnixMilli(mtimestamp), ""} } func newSinceID(id string) sinceMarker { From 83b5470bc57a24e25e0fa3daa65ab8573954c7a1 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 19:01:41 -0600 Subject: [PATCH 02/33] publish messages with a custom sequence ID --- server/errors.go | 1 + server/server.go | 31 +++++++++++++++++++- server/server_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/server/errors.go b/server/errors.go index c6745779..141adbd7 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,6 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} + errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: SID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 05b5b63a..aae4171e 100644 --- a/server/server.go +++ b/server/server.go @@ -79,6 +79,8 @@ var ( wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) + sidRegex = topicRegex + updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -542,7 +544,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) - } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { + } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) @@ -955,6 +957,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { + if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { + pathSID, err := s.sidFromPath(r.URL.Path) + if err != nil { + return false, false, "", "", "", false, err + } + m.SID = pathSID + } else { + sid := readParam(r, "x-sequence-id", "sequence-id", "sid") + if sid != "" { + if sidRegex.MatchString(sid) { + m.SID = sid + } else { + return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid + } + } else { + m.SID = m.ID + } + } cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -1693,6 +1713,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } +// sidFromPath returns the SID from a POST path like /mytopic/sidHere +func (s *Server) sidFromPath(path string) (string, *errHTTP) { + parts := strings.Split(path, "/") + if len(parts) != 3 { + return "", errHTTPBadRequestSIDInvalid + } + return parts[2], nil +} + // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() diff --git a/server/server_test.go b/server/server_test.go index 41633dd5..9270d010 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -703,6 +703,74 @@ func TestServer_PublishInvalidTopic(t *testing.T) { require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) } +func TestServer_PublishWithSIDInPath(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic/sid", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid", msg.SID) +} + +func TestServer_PublishWithSIDInHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic", "message", map[string]string{ + "sid": "sid", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid", msg.SID) +} + +func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{ + "sid": "sid2", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SID) // SID in path has priority over SID in header +} + +func TestServer_PublishWithSIDInQuery(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SID) +} + +func TestServer_PublishWithSIDViaGet(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SID) +} + +func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic/.", "message", nil) + + require.Equal(t, 404, response.Code) +} + +func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic", "message", map[string]string{ + "X-Sequence-ID": "*&?", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PollWithQueryFilters(t *testing.T) { s := newTestServer(t, newTestConfig(t)) From 8293a24cf9a91b9508c970057e9450fdabdca38d Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 22:10:11 -0600 Subject: [PATCH 03/33] update notification text using sid in web app --- web/public/static/langs/en.json | 2 ++ web/public/sw.js | 10 +++++- web/src/app/SubscriptionManager.js | 51 ++++++++++++++++++++++++---- web/src/app/db.js | 4 +-- web/src/app/notificationUtils.js | 12 +++++-- web/src/components/Notifications.jsx | 22 ++++++++++++ 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 3ad04ea7..362a3192 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,6 +70,8 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", + "notifications_sid": "Sequence ID", + "notifications_revisions": "Revisions", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/public/sw.js b/web/public/sw.js index 56d66f16..471cbee2 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -23,9 +23,17 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast"); const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); + const populatedMessage = message; + + if (!("mtime" in populatedMessage)) { + populatedMessage.mtime = message.time * 1000; + } + if (!("sid" in populatedMessage)) { + populatedMessage.sid = message.id; + } await db.notifications.add({ - ...message, + ...populatedMessage, subscriptionId, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation new: 1, diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index de99b642..00d15d89 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -156,18 +156,41 @@ class SubscriptionManager { // It's actually fine, because the reading and filtering is quite fast. The rendering is what's // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach - return this.db.notifications - .orderBy("time") // Sort by time first + const notifications = await this.db.notifications + .orderBy("mtime") // Sort by time first .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); + + return this.groupNotificationsBySID(notifications); } async getAllNotifications() { - return this.db.notifications - .orderBy("time") // Efficient, see docs + const notifications = await this.db.notifications + .orderBy("mtime") // Efficient, see docs .reverse() .toArray(); + + return this.groupNotificationsBySID(notifications); + } + + // Collapse notification updates based on sids + groupNotificationsBySID(notifications) { + const results = {}; + notifications.forEach((notification) => { + const key = `${notification.subscriptionId}:${notification.sid}`; + if (key in results) { + if ("history" in results[key]) { + results[key].history.push(notification); + } else { + results[key].history = [notification]; + } + } else { + results[key] = notification; + } + }); + + return Object.values(results); } /** Adds notification, or returns false if it already exists */ @@ -177,9 +200,16 @@ class SubscriptionManager { return false; } try { + const populatedNotification = notification; + if (!("mtime" in populatedNotification)) { + populatedNotification.mtime = notification.time * 1000; + } + if (!("sid" in populatedNotification)) { + populatedNotification.sid = notification.id; + } // sw.js duplicates this logic, so if you change it here, change it there too await this.db.notifications.add({ - ...notification, + ...populatedNotification, subscriptionId, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation new: 1, @@ -195,7 +225,16 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId })); + const notificationsWithSubscriptionId = notifications.map((notification) => { + const populatedNotification = notification; + if (!("mtime" in populatedNotification)) { + populatedNotification.mtime = notification.time * 1000; + } + if (!("sid" in populatedNotification)) { + populatedNotification.sid = notification.id; + } + return { ...populatedNotification, subscriptionId }; + }); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { diff --git a/web/src/app/db.js b/web/src/app/db.js index b28fb716..f4d36c1b 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,9 +11,9 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(2).stores({ + db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance + notifications: "&id,sid,subscriptionId,time,mtime,new,[subscriptionId+new]", // compound key for query performance users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 0bd5136d..2884e2f3 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -53,6 +53,14 @@ export const badge = "/static/images/mask-icon.svg"; export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; + let tag; + + if (message.sid) { + tag = message.sid; + } else { + tag = subscriptionId; + } + // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API return [ formatTitleWithDefault(message, defaultTitle), @@ -61,8 +69,8 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to badge, icon, image, - timestamp: message.time * 1_000, - tag: subscriptionId, + timestamp: message.mtime, + tag, renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index dceb5b91..40789d08 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -233,10 +233,20 @@ const NotificationItem = (props) => { const handleDelete = async () => { console.log(`[Notifications] Deleting notification ${notification.id}`); await subscriptionManager.deleteNotification(notification.id); + notification.history?.forEach(async (revision) => { + console.log(`[Notifications] Deleting revision ${revision.id}`); + await subscriptionManager.deleteNotification(revision.id); + }); }; const handleMarkRead = async () => { console.log(`[Notifications] Marking notification ${notification.id} as read`); await subscriptionManager.markNotificationRead(notification.id); + notification.history + ?.filter((revision) => revision.new === 1) + .forEach(async (revision) => { + console.log(`[Notifications] Marking revision ${revision.id} as read`); + await subscriptionManager.markNotificationRead(revision.id); + }); }; const handleCopy = (s) => { navigator.clipboard.writeText(s); @@ -248,6 +258,8 @@ const NotificationItem = (props) => { const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; + const showSid = notification.id !== notification.sid || notification.history; + return ( @@ -304,6 +316,16 @@ const NotificationItem = (props) => { {t("notifications_tags")}: {tags} )} + {showSid && ( + + {t("notifications_sid")}: {notification.sid} + + )} + {notification.history && ( + + {t("notifications_revisions")}: {notification.history.length + 1} + + )} {showActions && ( From aca9a774984789ca5b5e83cddd79cf42bc0e0e1f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 5 Jan 2026 21:14:29 -0500 Subject: [PATCH 04/33] Remove mtime --- server/message_cache.go | 37 ++++++++++--------------- server/message_cache_test.go | 22 ++------------- server/server.go | 6 ++-- server/types.go | 27 ++++++++++-------- web/public/static/langs/en.json | 3 +- web/public/sw.js | 3 -- web/src/app/SubscriptionManager.js | 41 +++++++++++++++------------- web/src/app/db.js | 4 +-- web/src/app/notificationUtils.js | 2 +- web/src/components/Notifications.jsx | 19 ++++--------- 10 files changed, 67 insertions(+), 97 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index 58080979..bd4dddd4 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -31,7 +31,6 @@ const ( mid TEXT NOT NULL, sid TEXT NOT NULL, time INT NOT NULL, - mtime INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -57,7 +56,6 @@ const ( CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); - CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); @@ -71,53 +69,53 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sid, time, mtime, 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, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? AND published = 1 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND id > ? AND published = 1 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND (id > ? OR published = 0) - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE time <= ? AND published = 0 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` @@ -270,10 +268,8 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN mtime INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); - CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); ` ) @@ -415,7 +411,6 @@ func (c *messageCache) addMessages(ms []*message) error { m.ID, m.SID, m.Time, - m.MTime, m.Expires, m.Topic, m.Message, @@ -723,14 +718,13 @@ func readMessages(rows *sql.Rows) ([]*message, error) { } func readMessage(rows *sql.Rows) (*message, error) { - var timestamp, mtimestamp, expires, attachmentSize, attachmentExpires int64 + var timestamp, expires, attachmentSize, attachmentExpires int64 var priority, deleted int var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, &sid, ×tamp, - &mtimestamp, &expires, &topic, &msg, @@ -782,7 +776,6 @@ func readMessage(rows *sql.Rows) (*message, error) { ID: id, SID: sid, Time: timestamp, - MTime: mtimestamp, Expires: expires, Event: messageEvent, Topic: topic, diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 18f69fd3..64203136 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -24,11 +24,9 @@ func TestMemCache_Messages(t *testing.T) { func testCacheMessages(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = 1 - m1.MTime = 1000 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = 2 - m2.MTime = 2000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) @@ -126,13 +124,10 @@ func testCacheMessagesScheduled(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m2 := newDefaultMessage("mytopic", "message 2") m2.Time = time.Now().Add(time.Hour).Unix() - m2.MTime = time.Now().Add(time.Hour).UnixMilli() m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! - m3.MTime = time.Now().Add(time.Minute).UnixMilli() // earlier than m2! + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! m4 := newDefaultMessage("mytopic2", "message 4") m4.Time = time.Now().Add(time.Minute).Unix() - m4.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) require.Nil(t, c.AddMessage(m3)) @@ -206,25 +201,18 @@ func TestMemCache_MessagesSinceID(t *testing.T) { func testCacheMessagesSinceID(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m1.Time = 100 - m1.MTime = 100000 m2 := newDefaultMessage("mytopic", "message 2") m2.Time = 200 - m2.MTime = 200000 m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 - m3.MTime = time.Now().Add(time.Hour).UnixMilli() // Scheduled, in the future, later than m7 and m5 + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 m4 := newDefaultMessage("mytopic", "message 4") m4.Time = 400 - m4.MTime = 400000 m5 := newDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 - m5.MTime = time.Now().Add(time.Minute).UnixMilli() // Scheduled, in the future, later than m7 + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 m6 := newDefaultMessage("mytopic", "message 6") m6.Time = 600 - m6.MTime = 600000 m7 := newDefaultMessage("mytopic", "message 7") m7.Time = 700 - m7.MTime = 700000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) @@ -285,17 +273,14 @@ func testCachePrune(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = now - 10 - m1.MTime = (now - 10) * 1000 m1.Expires = now - 5 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = now - 5 - m2.MTime = (now - 5) * 1000 m2.Expires = now + 5 // In the future m3 := newDefaultMessage("another_topic", "and another one") m3.Time = now - 12 - m3.MTime = (now - 12) * 1000 m3.Expires = now - 2 require.Nil(t, c.AddMessage(m1)) @@ -546,7 +531,6 @@ func TestSqliteCache_Migration_From1(t *testing.T) { // Add delayed message delayedMessage := newDefaultMessage("mytopic", "some delayed message") delayedMessage.Time = time.Now().Add(time.Minute).Unix() - delayedMessage.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(delayedMessage)) // 10, not 11! diff --git a/server/server.go b/server/server.go index 39c08c7d..9c612ebd 100644 --- a/server/server.go +++ b/server/server.go @@ -874,7 +874,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -1291,7 +1291,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(&msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } return buf.String(), nil @@ -1302,7 +1302,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(&msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } if msg.Event != messageEvent { diff --git a/server/types.go b/server/types.go index c8376673..467e80f5 100644 --- a/server/types.go +++ b/server/types.go @@ -24,10 +24,9 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid"` // Message sequence ID for updating message contents - Time int64 `json:"time"` // Unix time in seconds - MTime int64 `json:"mtime"` // Unix time in milliseconds + ID string `json:"id"` // Random message ID + SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` @@ -53,7 +52,6 @@ func (m *message) Context() log.Context { "message_id": m.ID, "message_sid": m.SID, "message_time": m.Time, - "message_mtime": m.MTime, "message_event": m.Event, "message_body_size": len(m.Message), } @@ -66,6 +64,16 @@ func (m *message) Context() log.Context { return fields } +// forJSON returns a copy of the message prepared for JSON output. +// It clears SID if it equals ID (to avoid redundant output). +func (m *message) forJSON() *message { + msg := *m + if msg.SID == msg.ID { + msg.SID = "" // Will be omitted due to omitempty + } + return &msg +} + type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -123,7 +131,6 @@ func newMessage(event, topic, msg string) *message { return &message{ ID: util.RandomString(messageIDLength), Time: time.Now().Unix(), - MTime: time.Now().UnixMilli(), Event: event, Topic: topic, Message: msg, @@ -162,11 +169,7 @@ type sinceMarker struct { } func newSinceTime(timestamp int64) sinceMarker { - return newSinceMTime(timestamp * 1000) -} - -func newSinceMTime(mtimestamp int64) sinceMarker { - return sinceMarker{time.UnixMilli(mtimestamp), ""} + return sinceMarker{time.Unix(timestamp, 0), ""} } func newSinceID(id string) sinceMarker { @@ -557,7 +560,7 @@ func newWebPushPayload(subscriptionID string, message *message) *webPushPayload return &webPushPayload{ Event: webPushMessageEvent, SubscriptionID: subscriptionID, - Message: message, + Message: message.forJSON(), } } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2094f0c2..0895b2eb 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,8 +70,7 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", - "notifications_sid": "Sequence ID", - "notifications_revisions": "Revisions", + "notifications_modified": "modified {{date}}", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/public/sw.js b/web/public/sw.js index 471cbee2..e010e4d4 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -25,9 +25,6 @@ const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); const populatedMessage = message; - if (!("mtime" in populatedMessage)) { - populatedMessage.mtime = message.time * 1000; - } if (!("sid" in populatedMessage)) { populatedMessage.sid = message.id; } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 00d15d89..086fc048 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -157,7 +157,7 @@ class SubscriptionManager { // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach const notifications = await this.db.notifications - .orderBy("mtime") // Sort by time first + .orderBy("time") // Sort by time .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); @@ -167,30 +167,39 @@ class SubscriptionManager { async getAllNotifications() { const notifications = await this.db.notifications - .orderBy("mtime") // Efficient, see docs + .orderBy("time") // Efficient, see docs .reverse() .toArray(); return this.groupNotificationsBySID(notifications); } - // Collapse notification updates based on sids + // Collapse notification updates based on sids, keeping only the latest version + // Also tracks the original time (earliest) for each sequence groupNotificationsBySID(notifications) { - const results = {}; + const latestBySid = {}; + const originalTimeBySid = {}; + notifications.forEach((notification) => { const key = `${notification.subscriptionId}:${notification.sid}`; - if (key in results) { - if ("history" in results[key]) { - results[key].history.push(notification); - } else { - results[key].history = [notification]; - } - } else { - results[key] = notification; + + // Track the latest notification for each sid (first one since sorted DESC) + if (!(key in latestBySid)) { + latestBySid[key] = notification; + } + + // Track the original (earliest) time for each sid + const currentOriginal = originalTimeBySid[key]; + if (currentOriginal === undefined || notification.time < currentOriginal) { + originalTimeBySid[key] = notification.time; } }); - return Object.values(results); + // Return latest notifications with originalTime set + return Object.entries(latestBySid).map(([key, notification]) => ({ + ...notification, + originalTime: originalTimeBySid[key], + })); } /** Adds notification, or returns false if it already exists */ @@ -201,9 +210,6 @@ class SubscriptionManager { } try { const populatedNotification = notification; - if (!("mtime" in populatedNotification)) { - populatedNotification.mtime = notification.time * 1000; - } if (!("sid" in populatedNotification)) { populatedNotification.sid = notification.id; } @@ -227,9 +233,6 @@ class SubscriptionManager { async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { const populatedNotification = notification; - if (!("mtime" in populatedNotification)) { - populatedNotification.mtime = notification.time * 1000; - } if (!("sid" in populatedNotification)) { populatedNotification.sid = notification.id; } diff --git a/web/src/app/db.js b/web/src/app/db.js index f4d36c1b..f11a5d0b 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,9 +11,9 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(3).stores({ + db.version(4).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,mtime,new,[subscriptionId+new]", // compound key for query performance + notifications: "&id,sid,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 2884e2f3..55d398c9 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -69,7 +69,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to badge, icon, image, - timestamp: message.mtime, + timestamp: message.time * 1000, tag, renotify: true, silent: false, diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 6872f46e..343a284a 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -236,7 +236,9 @@ const NotificationItem = (props) => { const { t, i18n } = useTranslation(); const { notification } = props; const { attachment } = notification; - const date = formatShortDateTime(notification.time, i18n.language); + const isModified = notification.originalTime && notification.originalTime !== notification.time; + const originalDate = formatShortDateTime(notification.originalTime || notification.time, i18n.language); + const modifiedDate = isModified ? formatShortDateTime(notification.time, i18n.language) : null; const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -267,8 +269,6 @@ const NotificationItem = (props) => { const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; - const showSid = notification.id !== notification.sid || notification.history; - return ( @@ -289,7 +289,8 @@ const NotificationItem = (props) => { )} - {date} + {originalDate} + {modifiedDate && ` (${t("notifications_modified", { date: modifiedDate })})`} {[1, 2, 4, 5].includes(notification.priority) && ( { {t("notifications_tags")}: {tags} )} - {showSid && ( - - {t("notifications_sid")}: {notification.sid} - - )} - {notification.history && ( - - {t("notifications_revisions")}: {notification.history.length + 1} - - )} {showActions && ( From f51e99dc803ced7399730fd2560c3db00a979a57 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 5 Jan 2026 21:55:07 -0500 Subject: [PATCH 05/33] Remove modified --- web/public/static/langs/en.json | 1 - web/src/app/SubscriptionManager.js | 19 ++----------------- web/src/components/Notifications.jsx | 7 ++----- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 0895b2eb..b0d3c545 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,7 +70,6 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", - "notifications_modified": "modified {{date}}", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 086fc048..ccce5ccc 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -175,31 +175,16 @@ class SubscriptionManager { } // Collapse notification updates based on sids, keeping only the latest version - // Also tracks the original time (earliest) for each sequence groupNotificationsBySID(notifications) { const latestBySid = {}; - const originalTimeBySid = {}; - notifications.forEach((notification) => { const key = `${notification.subscriptionId}:${notification.sid}`; - - // Track the latest notification for each sid (first one since sorted DESC) + // Keep only the first (latest by time) notification for each sid if (!(key in latestBySid)) { latestBySid[key] = notification; } - - // Track the original (earliest) time for each sid - const currentOriginal = originalTimeBySid[key]; - if (currentOriginal === undefined || notification.time < currentOriginal) { - originalTimeBySid[key] = notification.time; - } }); - - // Return latest notifications with originalTime set - return Object.entries(latestBySid).map(([key, notification]) => ({ - ...notification, - originalTime: originalTimeBySid[key], - })); + return Object.values(latestBySid); } /** Adds notification, or returns false if it already exists */ diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 343a284a..94185b7c 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -236,9 +236,7 @@ const NotificationItem = (props) => { const { t, i18n } = useTranslation(); const { notification } = props; const { attachment } = notification; - const isModified = notification.originalTime && notification.originalTime !== notification.time; - const originalDate = formatShortDateTime(notification.originalTime || notification.time, i18n.language); - const modifiedDate = isModified ? formatShortDateTime(notification.time, i18n.language) : null; + const date = formatShortDateTime(notification.time, i18n.language); const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -289,8 +287,7 @@ const NotificationItem = (props) => { )} - {originalDate} - {modifiedDate && ` (${t("notifications_modified", { date: modifiedDate })})`} + {date} {[1, 2, 4, 5].includes(notification.priority) && ( Date: Tue, 6 Jan 2026 14:22:55 -0500 Subject: [PATCH 06/33] Deleted --- server/message_cache.go | 7 +++-- server/server.go | 48 ++++++++++++++++++++++++++++++ server/types.go | 12 ++++---- web/public/sw.js | 6 ++++ web/src/app/SubscriptionManager.js | 4 ++- web/src/app/db.js | 7 +++++ 6 files changed, 74 insertions(+), 10 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index bd4dddd4..589d06f8 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -75,7 +75,7 @@ const ( 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 = ` + selectMessagesByIDQuery = ` SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? @@ -431,7 +431,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - 0, + m.Deleted, ) if err != nil { return err @@ -719,8 +719,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 - var priority, deleted int + var priority int var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + var deleted bool err := rows.Scan( &id, &sid, diff --git a/server/server.go b/server/server.go index 9c612ebd..67fe328d 100644 --- a/server/server.go +++ b/server/server.go @@ -547,6 +547,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) + } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -902,6 +904,52 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * return writeMatrixSuccess(w) } +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + t, err := fromContext[*topic](r, contextTopic) + if err != nil { + return err + } + vrate, err := fromContext[*visitor](r, contextRateVisitor) + if err != nil { + return err + } + if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { + return errHTTPTooManyRequestsLimitMessages.With(t) + } + sid, e := s.sidFromPath(r.URL.Path) + if e != nil { + return e.With(t) + } + // Create a delete message: empty body, same SID, deleted flag set + m := newDefaultMessage(t.ID, "") + m.SID = sid + m.Deleted = true + m.Sender = v.IP() + m.User = v.MaybeUserID() + m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() + // Publish to subscribers + if err := t.Publish(v, m); err != nil { + return err + } + // Send to Firebase for Android clients + if s.firebaseClient != nil { + go s.sendToFirebase(v, m) + } + // Send to web push endpoints + if s.config.WebPushPublicKey != "" { + go s.publishToWebPushEndpoints(v, m) + } + // Add to message cache + if err := s.messageCache.AddMessage(m); err != nil { + return err + } + logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with SID %s", sid) + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return s.writeJSON(w, m.forJSON()) +} + func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { diff --git a/server/types.go b/server/types.go index 467e80f5..88110b0d 100644 --- a/server/types.go +++ b/server/types.go @@ -24,14 +24,14 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) - Time int64 `json:"time"` // Unix time in seconds + ID string `json:"id"` // Random message ID + SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` + Message string `json:"message"` // Allow empty message body Priority int `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` Click string `json:"click,omitempty"` @@ -40,10 +40,10 @@ type message struct { Attachment *attachment `json:"attachment,omitempty"` PollID string `json:"poll_id,omitempty"` ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown - Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes + Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes + Deleted bool `json:"deleted,omitempty"` // True if message is marked as deleted Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments - Deleted int `json:"deleted,omitempty"` } func (m *message) Context() log.Context { diff --git a/web/public/sw.js b/web/public/sw.js index e010e4d4..33e97da8 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -57,6 +57,12 @@ const handlePushMessage = async (data) => { broadcastChannel.postMessage(message); // To potentially play sound await addNotification({ subscriptionId, message }); + + // Don't show a notification for deleted messages + if (message.deleted) { + return; + } + await self.registration.showNotification( ...toNotificationParams({ subscriptionId, diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index ccce5ccc..7d917e2d 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -175,6 +175,7 @@ class SubscriptionManager { } // Collapse notification updates based on sids, keeping only the latest version + // Filters out notifications where the latest in the sequence is deleted groupNotificationsBySID(notifications) { const latestBySid = {}; notifications.forEach((notification) => { @@ -184,7 +185,8 @@ class SubscriptionManager { latestBySid[key] = notification; } }); - return Object.values(latestBySid); + // Filter out notifications where the latest is deleted + return Object.values(latestBySid).filter((n) => !n.deleted); } /** Adds notification, or returns false if it already exists */ diff --git a/web/src/app/db.js b/web/src/app/db.js index f11a5d0b..0391388d 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -18,6 +18,13 @@ const createDatabase = (username) => { prefs: "&key", }); + db.version(5).stores({ + subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", + notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new]", // added deleted index + users: "&baseUrl,username", + prefs: "&key", + }); + return db; }; From 2dd152df3f198f524c31bc2f42242ef6eb61fb81 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 6 Jan 2026 18:02:08 -0500 Subject: [PATCH 07/33] Manual fixes for AI slop --- go.mod | 14 +- go.sum | 28 +-- server/server.go | 5 +- web/package-lock.json | 257 ++++++++++++++++----------- web/public/sw.js | 19 +- web/src/app/SubscriptionManager.js | 56 +++--- web/src/app/db.js | 12 +- web/src/app/notificationUtils.js | 10 +- web/src/app/utils.js | 7 + web/src/components/Notifications.jsx | 28 +-- web/src/components/hooks.js | 15 +- 11 files changed, 255 insertions(+), 196 deletions(-) diff --git a/go.mod b/go.mod index e6d91101..2b80f31b 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/emersion/go-smtp v0.18.0 github.com/gabriel-vasile/mimetype v1.4.12 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.32 + github.com/mattn/go-sqlite3 v1.14.33 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 @@ -21,7 +21,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.38.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.258.0 + google.golang.org/api v0.259.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -76,7 +76,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect @@ -95,10 +95,10 @@ require ( golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect + google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index babf7049..56e08d3b 100644 --- a/go.sum +++ b/go.sum @@ -112,8 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -131,8 +131,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -263,18 +263,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc= -google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM= -google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY= +google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/server/server.go b/server/server.go index 67fe328d..40a15e30 100644 --- a/server/server.go +++ b/server/server.go @@ -139,7 +139,8 @@ var ( const ( firebaseControlTopic = "~control" // See Android if changed firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now) - emptyMessageBody = "triggered" // Used if message body is empty + emptyMessageBody = "triggered" // Used when a message body is empty + deletedMessageBody = "deleted" // Used when a message is deleted newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages @@ -921,7 +922,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor return e.With(t) } // Create a delete message: empty body, same SID, deleted flag set - m := newDefaultMessage(t.ID, "") + m := newDefaultMessage(t.ID, deletedMessageBody) m.SID = sid m.Deleted = true m.Sender = v.IP() diff --git a/web/package-lock.json b/web/package-lock.json index 789b95f7..75bdd453 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2198,9 +2198,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2798,9 +2798,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], @@ -2812,9 +2812,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], @@ -2826,9 +2826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], @@ -2840,9 +2840,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], @@ -2854,9 +2854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "cpu": [ "arm64" ], @@ -2868,9 +2868,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "cpu": [ "x64" ], @@ -2882,9 +2882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], @@ -2896,9 +2896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], @@ -2910,9 +2910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], @@ -2924,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], @@ -2938,9 +2938,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "cpu": [ "loong64" ], @@ -2952,9 +2966,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], @@ -2966,9 +2994,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "cpu": [ "riscv64" ], @@ -2980,9 +3008,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], @@ -2994,9 +3022,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], @@ -3008,9 +3036,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], @@ -3022,9 +3050,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], @@ -3035,10 +3063,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], @@ -3050,9 +3092,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], @@ -3064,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], @@ -3078,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], @@ -3092,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], @@ -3566,9 +3608,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3781,9 +3823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", "dev": true, "funding": [ { @@ -4872,9 +4914,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4969,9 +5011,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -7586,9 +7628,9 @@ } }, "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7602,28 +7644,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, diff --git a/web/public/sw.js b/web/public/sw.js index 33e97da8..3370cd83 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -8,6 +8,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; +import { messageWithSID } from "../src/app/utils"; /** * General docs for service workers and PWAs: @@ -23,19 +24,17 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast"); const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); - const populatedMessage = message; - if (!("sid" in populatedMessage)) { - populatedMessage.sid = message.id; - } + // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + // Add notification to database await db.notifications.add({ - ...populatedMessage, + ...messageWithSID(message), subscriptionId, - // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - new: 1, + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); + // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { last: message.id, }); @@ -54,8 +53,7 @@ const addNotification = async ({ subscriptionId, message }) => { const handlePushMessage = async (data) => { const { subscription_id: subscriptionId, message } = data; - broadcastChannel.postMessage(message); // To potentially play sound - + // Add notification to database await addNotification({ subscriptionId, message }); // Don't show a notification for deleted messages @@ -63,6 +61,9 @@ const handlePushMessage = async (data) => { return; } + // Broadcast the message to potentially play a sound + broadcastChannel.postMessage(message); + await self.registration.showNotification( ...toNotificationParams({ subscriptionId, diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 7d917e2d..f5ea5a53 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,7 +2,7 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { topicUrl } from "./utils"; +import { messageWithSID, topicUrl } from "./utils"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -48,16 +48,17 @@ class SubscriptionManager { } async notify(subscriptionId, notification) { + if (notification.deleted) { + return; + } const subscription = await this.get(subscriptionId); if (subscription.mutedUntil > 0) { return; } - const priority = notification.priority ?? 3; if (priority < (await prefs.minPriority())) { return; } - await notifier.notify(subscription, notification); } @@ -82,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -100,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -196,19 +197,20 @@ class SubscriptionManager { return false; } try { - const populatedNotification = notification; - if (!("sid" in populatedNotification)) { - populatedNotification.sid = notification.id; - } - // sw.js duplicates this logic, so if you change it here, change it there too + // Note: Service worker (sw.js) and addNotifications() duplicates this logic, + // so if you change it here, change it there too. + + // Add notification to database await this.db.notifications.add({ - ...populatedNotification, + ...messageWithSID(notification), subscriptionId, - // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - new: 1, - }); // FIXME consider put() for double tab + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + }); + + // FIXME consider put() for double tab + // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -219,16 +221,12 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { - const populatedNotification = notification; - if (!("sid" in populatedNotification)) { - populatedNotification.sid = notification.id; - } - return { ...populatedNotification, subscriptionId }; + return { ...messageWithSID(notification), subscriptionId }; }); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -249,6 +247,10 @@ class SubscriptionManager { await this.db.notifications.delete(notificationId); } + async deleteNotificationBySid(subscriptionId, sid) { + await this.db.notifications.where({ subscriptionId, sid }).delete(); + } + async deleteNotifications(subscriptionId) { await this.db.notifications.where({ subscriptionId }).delete(); } @@ -257,25 +259,29 @@ class SubscriptionManager { await this.db.notifications.where({ id: notificationId }).modify({ new: 0 }); } + async markNotificationReadBySid(subscriptionId, sid) { + await this.db.notifications.where({ subscriptionId, sid }).modify({ new: 0 }); + } + async markNotificationsRead(subscriptionId) { await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 }); } async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 0391388d..1bda553f 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,16 +11,10 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(4).stores({ + db.version(6).stores({ + // FIXME Should be 3 subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance - users: "&baseUrl,username", - prefs: "&key", - }); - - db.version(5).stores({ - subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new]", // added deleted index + notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sid]", users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 55d398c9..6cb8bc37 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -53,14 +53,6 @@ export const badge = "/static/images/mask-icon.svg"; export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; - let tag; - - if (message.sid) { - tag = message.sid; - } else { - tag = subscriptionId; - } - // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API return [ formatTitleWithDefault(message, defaultTitle), @@ -70,7 +62,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to icon, image, timestamp: message.time * 1000, - tag, + tag: message.sid || message.id, // Update notification if there is a sequence ID renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 935f2024..d9f851b4 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,6 +103,13 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; +export const messageWithSID = (message) => { + if (!message.sid) { + message.sid = message.id; + } + return message; +}; + export const shuffle = (arr) => { const returnArr = [...arr]; diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 94185b7c..53c9085f 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -240,22 +240,22 @@ const NotificationItem = (props) => { const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id); - notification.history?.forEach(async (revision) => { - console.log(`[Notifications] Deleting revision ${revision.id}`); - await subscriptionManager.deleteNotification(revision.id); - }); + if (notification.sid) { + console.log(`[Notifications] Deleting all notifications with sid ${notification.sid}`); + await subscriptionManager.deleteNotificationBySid(notification.subscriptionId, notification.sid); + } else { + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id); + } }; const handleMarkRead = async () => { - console.log(`[Notifications] Marking notification ${notification.id} as read`); - await subscriptionManager.markNotificationRead(notification.id); - notification.history - ?.filter((revision) => revision.new === 1) - .forEach(async (revision) => { - console.log(`[Notifications] Marking revision ${revision.id} as read`); - await subscriptionManager.markNotificationRead(revision.id); - }); + if (notification.sid) { + console.log(`[Notifications] Marking notification with sid ${notification.sid} as read`); + await subscriptionManager.markNotificationReadBySid(notification.subscriptionId, notification.sid); + } else { + console.log(`[Notifications] Marking notification ${notification.id} as read`); + await subscriptionManager.markNotificationRead(notification.id); + } }; const handleCopy = (s) => { copyToClipboard(s); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 519d4c6a..4d852140 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -50,12 +50,23 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { + if (notification.deleted && notification.sid) { + return handleDeletedNotification(subscriptionId, notification); + } + return handleNewOrUpdatedNotification(subscriptionId, notification); + }; + + const handleNewOrUpdatedNotification = async (subscriptionId, notification) => { const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification); } }; + const handleDeletedNotification = async (subscriptionId, notification) => { + await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + }; + const handleMessage = async (subscriptionId, message) => { const subscription = await subscriptionManager.get(subscriptionId); @@ -231,7 +242,9 @@ export const useIsLaunchedPWA = () => { useEffect(() => { if (isIOSStandalone) { - return () => {}; // No need to listen for events on iOS + return () => { + // No need to listen for events on iOS + }; } const handler = (evt) => { console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`); From bfbe73aea36f44208f5795849b70ebabcd803a30 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 7 Jan 2026 09:46:08 -0500 Subject: [PATCH 08/33] Update polling --- web/src/app/Poller.js | 36 +++++++++++++++++++++++++----- web/src/app/SubscriptionManager.js | 2 +- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 2261dddc..8fefc94e 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -42,12 +42,22 @@ class Poller { const since = subscription.last; const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - if (!notifications || notifications.length === 0) { - console.log(`[Poller] No new notifications found for ${subscription.id}`); - return; + const deletedSids = this.deletedSids(notifications); + const newOrUpdatedNotifications = this.newOrUpdatedNotifications(notifications, deletedSids); + + // Delete all existing notifications with a deleted sequence ID + if (deletedSids.length > 0) { + console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`); + await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); + } + + // Add new or updated notifications + if (newOrUpdatedNotifications.length > 0) { + console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notifications); + } else { + console.log(`[Poller] No new notifications found for ${subscription.id}`); } - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); } pollInBackground(subscription) { @@ -59,6 +69,22 @@ class Poller { } })(); } + + deletedSids(notifications) { + return new Set( + notifications + .filter(n => n.sid && n.deleted) + .map(n => n.sid) + ); + } + + newOrUpdatedNotifications(notifications, deletedSids) { + return notifications + .filter((notification) => { + const sid = notification.sid || notification.id; + return !deletedSids.has(notification.id) && !deletedSids.has(sid) && !notification.deleted; + }); + } } const poller = new Poller(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index f5ea5a53..2b9260f7 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -193,7 +193,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists) { + if (exists || notification.deleted) { return false; } try { From 75abf2e245e7c5fa22b55f9cf437c58b180753e0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 10:28:02 -0500 Subject: [PATCH 09/33] Delete old messages with SID when new messages arrive --- web/public/sw.js | 5 ++++ web/src/app/Poller.js | 42 ++++++++++++++++-------------- web/src/app/SubscriptionManager.js | 26 ++++++++---------- web/src/components/hooks.js | 5 ++++ 4 files changed, 43 insertions(+), 35 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index 3370cd83..3a67da58 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -27,6 +27,11 @@ const addNotification = async ({ subscriptionId, message }) => { // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + // Delete existing notification with same SID (if any) + if (message.sid) { + await db.notifications.where({ subscriptionId, sid: message.sid }).delete(); + } + // Add notification to database await db.notifications.add({ ...messageWithSID(message), diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 8fefc94e..9f3ff6a0 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -42,19 +42,22 @@ class Poller { const since = subscription.last; const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - const deletedSids = this.deletedSids(notifications); - const newOrUpdatedNotifications = this.newOrUpdatedNotifications(notifications, deletedSids); + const latestBySid = this.latestNotificationsBySid(notifications); // Delete all existing notifications with a deleted sequence ID + const deletedSids = Object.entries(latestBySid) + .filter(([, notification]) => notification.deleted) + .map(([sid]) => sid); if (deletedSids.length > 0) { console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`); await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); } - // Add new or updated notifications - if (newOrUpdatedNotifications.length > 0) { - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); + // Add only the latest notification for each non-deleted sequence + const notificationsToAdd = Object.values(latestBySid).filter((n) => !n.deleted); + if (notificationsToAdd.length > 0) { + console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); } else { console.log(`[Poller] No new notifications found for ${subscription.id}`); } @@ -70,20 +73,19 @@ class Poller { })(); } - deletedSids(notifications) { - return new Set( - notifications - .filter(n => n.sid && n.deleted) - .map(n => n.sid) - ); - } - - newOrUpdatedNotifications(notifications, deletedSids) { - return notifications - .filter((notification) => { - const sid = notification.sid || notification.id; - return !deletedSids.has(notification.id) && !deletedSids.has(sid) && !notification.deleted; - }); + /** + * Groups notifications by sid and returns only the latest (highest time) for each sequence. + * Returns an object mapping sid -> latest notification. + */ + latestNotificationsBySid(notifications) { + const latestBySid = {}; + notifications.forEach((notification) => { + const sid = notification.sid || notification.id; + if (!(sid in latestBySid) || notification.time >= latestBySid[sid].time) { + latestBySid[sid] = notification; + } + }); + return latestBySid; } } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 2b9260f7..40cc475a 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } @@ -83,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null + last: null, }; await this.db.subscriptions.put(subscription); @@ -101,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation // May be null! + reservation, // May be null! }); return local.id; @@ -157,22 +157,18 @@ class SubscriptionManager { // It's actually fine, because the reading and filtering is quite fast. The rendering is what's // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach - const notifications = await this.db.notifications + return this.db.notifications .orderBy("time") // Sort by time .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); - - return this.groupNotificationsBySID(notifications); } async getAllNotifications() { - const notifications = await this.db.notifications + return this.db.notifications .orderBy("time") // Efficient, see docs .reverse() .toArray(); - - return this.groupNotificationsBySID(notifications); } // Collapse notification updates based on sids, keeping only the latest version @@ -204,13 +200,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSID(notification), subscriptionId, - new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id + last: notification.id, }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -226,7 +222,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId + last: lastNotificationId, }); } @@ -269,19 +265,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName + displayName, }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation + reservation, }); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 4d852140..588697fc 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -57,6 +57,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNewOrUpdatedNotification = async (subscriptionId, notification) => { + // Delete existing notification with same sid, if any + if (notification.sid) { + await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + } + // Add notification to database const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification); From 239959e2a4bc2a3f39b407414cf8d4a22c0706aa Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 11:19:53 -0500 Subject: [PATCH 10/33] Revert some changes; make poller respect deleteAfter pref --- web/src/app/Poller.js | 14 +++++++++--- web/src/app/SubscriptionManager.js | 33 ++++++++-------------------- web/src/components/Notifications.jsx | 18 ++++----------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 9f3ff6a0..5c7d2e2d 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,4 +1,5 @@ import api from "./Api"; +import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; const delayMillis = 2000; // 2 seconds @@ -42,14 +43,21 @@ class Poller { const since = subscription.last; const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - const latestBySid = this.latestNotificationsBySid(notifications); - // Delete all existing notifications with a deleted sequence ID + // Filter out notifications older than the prune threshold + const deleteAfterSeconds = await prefs.deleteAfter(); + const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0; + const recentNotifications = pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications; + + // Find the latest notification for each sequence ID + const latestBySid = this.latestNotificationsBySid(recentNotifications); + + // Delete all existing notifications for which the latest notification is marked as deleted const deletedSids = Object.entries(latestBySid) .filter(([, notification]) => notification.deleted) .map(([sid]) => sid); if (deletedSids.length > 0) { - console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`); + console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSids); await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 40cc475a..4a4c6f54 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -83,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -101,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -171,21 +171,6 @@ class SubscriptionManager { .toArray(); } - // Collapse notification updates based on sids, keeping only the latest version - // Filters out notifications where the latest in the sequence is deleted - groupNotificationsBySID(notifications) { - const latestBySid = {}; - notifications.forEach((notification) => { - const key = `${notification.subscriptionId}:${notification.sid}`; - // Keep only the first (latest by time) notification for each sid - if (!(key in latestBySid)) { - latestBySid[key] = notification; - } - }); - // Filter out notifications where the latest is deleted - return Object.values(latestBySid).filter((n) => !n.deleted); - } - /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); @@ -200,13 +185,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSID(notification), subscriptionId, - new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -222,7 +207,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -265,19 +250,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 53c9085f..449b238b 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -240,22 +240,12 @@ const NotificationItem = (props) => { const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { - if (notification.sid) { - console.log(`[Notifications] Deleting all notifications with sid ${notification.sid}`); - await subscriptionManager.deleteNotificationBySid(notification.subscriptionId, notification.sid); - } else { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id); - } + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id); }; const handleMarkRead = async () => { - if (notification.sid) { - console.log(`[Notifications] Marking notification with sid ${notification.sid} as read`); - await subscriptionManager.markNotificationReadBySid(notification.subscriptionId, notification.sid); - } else { - console.log(`[Notifications] Marking notification ${notification.id} as read`); - await subscriptionManager.markNotificationRead(notification.id); - } + console.log(`[Notifications] Marking notification ${notification.id} as read`); + await subscriptionManager.markNotificationRead(notification.id); }; const handleCopy = (s) => { copyToClipboard(s); From dffee9ea7d64cfb928ed31d1efb61490252c6d57 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 11:39:32 -0500 Subject: [PATCH 11/33] Remove forJSON --- server/message_cache.go | 4 ++++ server/server.go | 8 ++++---- server/types.go | 12 +----------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index 589d06f8..1752c2c9 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -773,6 +773,10 @@ func readMessage(rows *sql.Rows) (*message, error) { URL: attachmentURL, } } + // Clear SID if it equals ID (we do not want the SID in the message output) + if sid == id { + sid = "" + } return &message{ ID: id, SID: sid, diff --git a/server/server.go b/server/server.go index 40a15e30..edae9f2a 100644 --- a/server/server.go +++ b/server/server.go @@ -877,7 +877,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m.forJSON()) + return s.writeJSON(w, m) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -948,7 +948,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor s.mu.Lock() s.messages++ s.mu.Unlock() - return s.writeJSON(w, m.forJSON()) + return s.writeJSON(w, m) } func (s *Server) sendToFirebase(v *visitor, m *message) { @@ -1340,7 +1340,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { + if err := json.NewEncoder(&buf).Encode(msg); err != nil { return "", err } return buf.String(), nil @@ -1351,7 +1351,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { + if err := json.NewEncoder(&buf).Encode(msg); err != nil { return "", err } if msg.Event != messageEvent { diff --git a/server/types.go b/server/types.go index 88110b0d..b68721a3 100644 --- a/server/types.go +++ b/server/types.go @@ -64,16 +64,6 @@ func (m *message) Context() log.Context { return fields } -// forJSON returns a copy of the message prepared for JSON output. -// It clears SID if it equals ID (to avoid redundant output). -func (m *message) forJSON() *message { - msg := *m - if msg.SID == msg.ID { - msg.SID = "" // Will be omitted due to omitempty - } - return &msg -} - type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -560,7 +550,7 @@ func newWebPushPayload(subscriptionID string, message *message) *webPushPayload return &webPushPayload{ Event: webPushMessageEvent, SubscriptionID: subscriptionID, - Message: message.forJSON(), + Message: message, } } From 7cffbfcd6d78c02df0e9b1da2353451f359ed8f1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 13:43:57 -0500 Subject: [PATCH 12/33] Simplify handleNotifications --- web/src/components/hooks.js | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 588697fc..9f836156 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -50,26 +50,18 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { - if (notification.deleted && notification.sid) { - return handleDeletedNotification(subscriptionId, notification); - } - return handleNewOrUpdatedNotification(subscriptionId, notification); - }; - - const handleNewOrUpdatedNotification = async (subscriptionId, notification) => { // Delete existing notification with same sid, if any if (notification.sid) { await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); } - // Add notification to database - const added = await subscriptionManager.addNotification(subscriptionId, notification); - if (added) { - await subscriptionManager.notify(subscriptionId, notification); - } - }; - const handleDeletedNotification = async (subscriptionId, notification) => { - await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + // Add notification to database + if (!notification.deleted) { + const added = await subscriptionManager.addNotification(subscriptionId, notification); + if (added) { + await subscriptionManager.notify(subscriptionId, notification); + } + } }; const handleMessage = async (subscriptionId, message) => { From fd8cd5ca91560dee8c63cdff5bf96f1914129a4c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 13:45:05 -0500 Subject: [PATCH 13/33] Comment --- web/src/components/hooks.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 9f836156..3b171eab 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -50,11 +50,13 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { + // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() + // and FirebaseService::handleMessage(). + // Delete existing notification with same sid, if any if (notification.sid) { await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); } - // Add notification to database if (!notification.deleted) { const added = await subscriptionManager.addNotification(subscriptionId, notification); From 1ab7ca876c978f230dd0cd0ed26ffd394b781116 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 14:27:18 -0500 Subject: [PATCH 14/33] Rename to sequence_id --- server/errors.go | 2 +- server/message_cache.go | 38 ++++++++++---------- server/message_cache_test.go | 14 ++++---- server/server.go | 26 +++++++------- server/server_test.go | 10 +++--- server/types.go | 56 +++++++++++++++--------------- web/public/sw.js | 11 +++--- web/src/app/Poller.js | 35 ++++++++++--------- web/src/app/SubscriptionManager.js | 32 ++++++++--------- web/src/app/db.js | 7 ++-- web/src/app/notificationUtils.js | 2 +- web/src/app/utils.js | 6 ++-- web/src/components/hooks.js | 7 ++-- 13 files changed, 125 insertions(+), 121 deletions(-) diff --git a/server/errors.go b/server/errors.go index 302c06a7..950d23fc 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,7 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: SID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/message_cache.go b/server/message_cache.go index 1752c2c9..c00c67c8 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -29,7 +29,7 @@ const ( CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, - sid TEXT NOT NULL, + sequence_id TEXT NOT NULL, time INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, @@ -54,7 +54,7 @@ const ( deleted INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); - CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); + CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); @@ -69,50 +69,50 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id @@ -267,9 +267,9 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); - CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); + CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); ` ) @@ -409,7 +409,7 @@ func (c *messageCache) addMessages(ms []*message) error { } _, err := stmt.Exec( m.ID, - m.SID, + m.SequenceID, m.Time, m.Expires, m.Topic, @@ -720,11 +720,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string var deleted bool err := rows.Scan( &id, - &sid, + &sequenceID, ×tamp, &expires, &topic, @@ -773,13 +773,13 @@ func readMessage(rows *sql.Rows) (*message, error) { URL: attachmentURL, } } - // Clear SID if it equals ID (we do not want the SID in the message output) - if sid == id { - sid = "" + // Clear SequenceID if it equals ID (we do not want the SequenceID in the message output) + if sequenceID == id { + sequenceID = "" } return &message{ ID: id, - SID: sid, + SequenceID: sequenceID, Time: timestamp, Expires: expires, Event: messageEvent, diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 64203136..1e285605 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -319,7 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" - m.SID = "m1" + m.SequenceID = "m1" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "flower.jpg", @@ -333,7 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires2 := time.Now().Add(2 * time.Hour).Unix() // Future m = newDefaultMessage("mytopic", "sending you a car") m.ID = "m2" - m.SID = "m2" + m.SequenceID = "m2" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "car.jpg", @@ -347,7 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires3 := time.Now().Add(1 * time.Hour).Unix() // Future m = newDefaultMessage("another-topic", "sending you another car") m.ID = "m3" - m.SID = "m3" + m.SequenceID = "m3" m.User = "u_BAsbaAa" m.Sender = netip.MustParseAddr("5.6.7.8") m.Attachment = &attachment{ @@ -403,13 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) { func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" - m.SID = "m1" + m.SequenceID = "m1" m.Expires = time.Now().Add(time.Hour).Unix() require.Nil(t, c.AddMessage(m)) m = newDefaultMessage("mytopic", "message with attachment") m.ID = "m2" - m.SID = "m2" + m.SequenceID = "m2" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -422,7 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic", "message with external attachment") m.ID = "m3" - m.SID = "m3" + m.SequenceID = "m3" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -434,7 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic2", "message with expired attachment") m.ID = "m4" - m.SID = "m4" + m.SequenceID = "m4" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "expired-car.jpg", diff --git a/server/server.go b/server/server.go index edae9f2a..c60154ec 100644 --- a/server/server.go +++ b/server/server.go @@ -917,13 +917,13 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return errHTTPTooManyRequestsLimitMessages.With(t) } - sid, e := s.sidFromPath(r.URL.Path) + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) if e != nil { return e.With(t) } - // Create a delete message: empty body, same SID, deleted flag set + // Create a delete message: empty body, same SequenceID, deleted flag set m := newDefaultMessage(t.ID, deletedMessageBody) - m.SID = sid + m.SequenceID = sequenceID m.Deleted = true m.Sender = v.IP() m.User = v.MaybeUserID() @@ -944,7 +944,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if err := s.messageCache.AddMessage(m); err != nil { return err } - logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with SID %s", sid) + logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with sequence ID %s", sequenceID) s.mu.Lock() s.messages++ s.mu.Unlock() @@ -1009,21 +1009,21 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { - pathSID, err := s.sidFromPath(r.URL.Path) + pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path) if err != nil { return false, false, "", "", "", false, err } - m.SID = pathSID + m.SequenceID = pathSequenceID } else { - sid := readParam(r, "x-sequence-id", "sequence-id", "sid") - if sid != "" { - if sidRegex.MatchString(sid) { - m.SID = sid + sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid") + if sequenceID != "" { + if sidRegex.MatchString(sequenceID) { + m.SequenceID = sequenceID } else { return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid } } else { - m.SID = m.ID + m.SequenceID = m.ID } } cache = readBoolParam(r, true, "x-cache", "cache") @@ -1764,8 +1764,8 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } -// sidFromPath returns the SID from a POST path like /mytopic/sidHere -func (s *Server) sidFromPath(path string) (string, *errHTTP) { +// sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere +func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") if len(parts) != 3 { return "", errHTTPBadRequestSIDInvalid diff --git a/server/server_test.go b/server/server_test.go index c1b78c63..2978947f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -684,7 +684,7 @@ func TestServer_PublishWithSIDInPath(t *testing.T) { response := request(t, s, "POST", "/mytopic/sid", "message", nil) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid", msg.SID) + require.Equal(t, "sid", msg.SequenceID) } func TestServer_PublishWithSIDInHeader(t *testing.T) { @@ -695,7 +695,7 @@ func TestServer_PublishWithSIDInHeader(t *testing.T) { }) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid", msg.SID) + require.Equal(t, "sid", msg.SequenceID) } func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { @@ -706,7 +706,7 @@ func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { }) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SID) // SID in path has priority over SID in header + require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header } func TestServer_PublishWithSIDInQuery(t *testing.T) { @@ -715,7 +715,7 @@ func TestServer_PublishWithSIDInQuery(t *testing.T) { response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SID) + require.Equal(t, "sid1", msg.SequenceID) } func TestServer_PublishWithSIDViaGet(t *testing.T) { @@ -724,7 +724,7 @@ func TestServer_PublishWithSIDViaGet(t *testing.T) { response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SID) + require.Equal(t, "sid1", msg.SequenceID) } func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { diff --git a/server/types.go b/server/types.go index b68721a3..e9c0fdb8 100644 --- a/server/types.go +++ b/server/types.go @@ -24,11 +24,11 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) - Time int64 `json:"time"` // Unix time in seconds - Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) - Event string `json:"event"` // One of the above + ID string `json:"id"` // Random message ID + SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds + Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) + Event string `json:"event"` // One of the above Topic string `json:"topic"` Title string `json:"title,omitempty"` Message string `json:"message"` // Allow empty message body @@ -48,12 +48,12 @@ type message struct { func (m *message) Context() log.Context { fields := map[string]any{ - "topic": m.Topic, - "message_id": m.ID, - "message_sid": m.SID, - "message_time": m.Time, - "message_event": m.Event, - "message_body_size": len(m.Message), + "topic": m.Topic, + "message_id": m.ID, + "message_sequence_id": m.SequenceID, + "message_time": m.Time, + "message_event": m.Event, + "message_body_size": len(m.Message), } if m.Sender.IsValid() { fields["message_sender"] = m.Sender.String() @@ -94,23 +94,23 @@ func newAction() *action { // publishMessage is used as input when publishing as JSON type publishMessage struct { - Topic string `json:"topic"` - SID string `json:"sid"` - Title string `json:"title"` - Message string `json:"message"` - Priority int `json:"priority"` - Tags []string `json:"tags"` - Click string `json:"click"` - Icon string `json:"icon"` - Actions []action `json:"actions"` - Attach string `json:"attach"` - Markdown bool `json:"markdown"` - Filename string `json:"filename"` - Email string `json:"email"` - Call string `json:"call"` - Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) - Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead) - Delay string `json:"delay"` + Topic string `json:"topic"` + SequenceID string `json:"sequence_id"` + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + Tags []string `json:"tags"` + Click string `json:"click"` + Icon string `json:"icon"` + Actions []action `json:"actions"` + Attach string `json:"attach"` + Markdown bool `json:"markdown"` + Filename string `json:"filename"` + Email string `json:"email"` + Call string `json:"call"` + Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) + Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead) + Delay string `json:"delay"` } // messageEncoder is a function that knows how to encode a message diff --git a/web/public/sw.js b/web/public/sw.js index 3a67da58..38dbc9c1 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -8,7 +8,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { messageWithSID } from "../src/app/utils"; +import { messageWithSequenceId } from "../src/app/utils"; /** * General docs for service workers and PWAs: @@ -27,14 +27,15 @@ const addNotification = async ({ subscriptionId, message }) => { // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too - // Delete existing notification with same SID (if any) - if (message.sid) { - await db.notifications.where({ subscriptionId, sid: message.sid }).delete(); + // Delete existing notification with same sequence ID (if any) + const sequenceId = message.sequence_id || message.id; + if (sequenceId) { + await db.notifications.where({ subscriptionId, sequenceId }).delete(); } // Add notification to database await db.notifications.add({ - ...messageWithSID(message), + ...messageWithSequenceId(message), subscriptionId, new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 5c7d2e2d..aa0e6dba 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -47,22 +47,25 @@ class Poller { // Filter out notifications older than the prune threshold const deleteAfterSeconds = await prefs.deleteAfter(); const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0; - const recentNotifications = pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications; + const recentNotifications = + pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications; // Find the latest notification for each sequence ID - const latestBySid = this.latestNotificationsBySid(recentNotifications); + const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications); // Delete all existing notifications for which the latest notification is marked as deleted - const deletedSids = Object.entries(latestBySid) + const deletedSequenceIds = Object.entries(latestBySequenceId) .filter(([, notification]) => notification.deleted) - .map(([sid]) => sid); - if (deletedSids.length > 0) { - console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSids); - await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); + .map(([sequenceId]) => sequenceId); + if (deletedSequenceIds.length > 0) { + console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds); + await Promise.all( + deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId)) + ); } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object.values(latestBySid).filter((n) => !n.deleted); + const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => !n.deleted); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); @@ -82,18 +85,18 @@ class Poller { } /** - * Groups notifications by sid and returns only the latest (highest time) for each sequence. - * Returns an object mapping sid -> latest notification. + * Groups notifications by sequenceId and returns only the latest (highest time) for each sequence. + * Returns an object mapping sequenceId -> latest notification. */ - latestNotificationsBySid(notifications) { - const latestBySid = {}; + latestNotificationsBySequenceId(notifications) { + const latestBySequenceId = {}; notifications.forEach((notification) => { - const sid = notification.sid || notification.id; - if (!(sid in latestBySid) || notification.time >= latestBySid[sid].time) { - latestBySid[sid] = notification; + const sequenceId = notification.sequence_id || notification.id; + if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) { + latestBySequenceId[sequenceId] = notification; } }); - return latestBySid; + return latestBySequenceId; } } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 4a4c6f54..772b30a7 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,7 +2,7 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { messageWithSID, topicUrl } from "./utils"; +import { messageWithSequenceId, topicUrl } from "./utils"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } @@ -83,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null + last: null, }; await this.db.subscriptions.put(subscription); @@ -101,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation // May be null! + reservation, // May be null! }); return local.id; @@ -183,15 +183,15 @@ class SubscriptionManager { // Add notification to database await this.db.notifications.add({ - ...messageWithSID(notification), + ...messageWithSequenceId(notification), subscriptionId, - new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id + last: notification.id, }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -202,12 +202,12 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { - return { ...messageWithSID(notification), subscriptionId }; + return { ...messageWithSequenceId(notification), subscriptionId }; }); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId + last: lastNotificationId, }); } @@ -228,8 +228,8 @@ class SubscriptionManager { await this.db.notifications.delete(notificationId); } - async deleteNotificationBySid(subscriptionId, sid) { - await this.db.notifications.where({ subscriptionId, sid }).delete(); + async deleteNotificationBySequenceId(subscriptionId, sequenceId) { + await this.db.notifications.where({ subscriptionId, sequenceId }).delete(); } async deleteNotifications(subscriptionId) { @@ -240,8 +240,8 @@ class SubscriptionManager { await this.db.notifications.where({ id: notificationId }).modify({ new: 0 }); } - async markNotificationReadBySid(subscriptionId, sid) { - await this.db.notifications.where({ subscriptionId, sid }).modify({ new: 0 }); + async markNotificationReadBySequenceId(subscriptionId, sequenceId) { + await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } async markNotificationsRead(subscriptionId) { @@ -250,19 +250,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName + displayName, }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation + reservation, }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 1bda553f..7e3c47e3 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,12 +11,11 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(6).stores({ - // FIXME Should be 3 + db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sid]", + notifications: "&id,sequenceId,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", - prefs: "&key", + prefs: "&key" }); return db; diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 6cb8bc37..65b5bd3d 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -62,7 +62,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to icon, image, timestamp: message.time * 1000, - tag: message.sid || message.id, // Update notification if there is a sequence ID + tag: message.sequence_id || message.id, // Update notification if there is a sequence ID renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/app/utils.js b/web/src/app/utils.js index d9f851b4..9aeada05 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,9 +103,9 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; -export const messageWithSID = (message) => { - if (!message.sid) { - message.sid = message.id; +export const messageWithSequenceId = (message) => { + if (!message.sequenceId) { + message.sequenceId = message.sequence_id || message.id; } return message; }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 3b171eab..5b50f0a8 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -53,9 +53,10 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() // and FirebaseService::handleMessage(). - // Delete existing notification with same sid, if any - if (notification.sid) { - await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + // Delete existing notification with same sequenceId, if any + const sequenceId = notification.sequence_id || notification.id; + if (sequenceId) { + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); } // Add notification to database if (!notification.deleted) { From 66ea25c18b76fe5ba167b052caddcc57fd3219a4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 15:45:50 -0500 Subject: [PATCH 15/33] Add JSON publishing support --- server/server.go | 3 +++ server/server_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/server/server.go b/server/server.go index c60154ec..635258fa 100644 --- a/server/server.go +++ b/server/server.go @@ -2027,6 +2027,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Firebase != "" { r.Header.Set("X-Firebase", m.Firebase) } + if m.SequenceID != "" { + r.Header.Set("X-Sequence-ID", m.SequenceID) + } return next(w, r, v) } } diff --git a/server/server_test.go b/server/server_test.go index 2978947f..964d6156 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -727,6 +727,18 @@ func TestServer_PublishWithSIDViaGet(t *testing.T) { require.Equal(t, "sid1", msg.SequenceID) } +func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) + + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "my-sequence-123", msg.SequenceID) +} + func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { s := newTestServer(t, newTestConfig(t)) From 5ad3de290400af8db89d0602977ca50bea91fa7e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 20:50:23 -0500 Subject: [PATCH 16/33] Switch to event type --- server/errors.go | 2 +- server/message_cache.go | 39 +++++++++--------- server/server.go | 64 +++++++++++++++++++++++++----- server/types.go | 20 +++++++--- web/public/sw.js | 57 +++++++++++++++++++++++--- web/src/app/Connection.js | 6 ++- web/src/app/Poller.js | 7 +++- web/src/app/SubscriptionManager.js | 23 ++++++----- web/src/app/db.js | 2 +- web/src/app/events.js | 14 +++++++ web/src/components/hooks.js | 20 ++++++---- 11 files changed, 187 insertions(+), 67 deletions(-) create mode 100644 web/src/app/events.js diff --git a/server/errors.go b/server/errors.go index 950d23fc..e8f58d75 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,7 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/message_cache.go b/server/message_cache.go index c00c67c8..396cd7a2 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -51,7 +51,7 @@ const ( content_type TEXT NOT NULL, encoding TEXT NOT NULL, published INT NOT NULL, - deleted INT NOT NULL + event TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); @@ -69,58 +69,57 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, event) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id ` - selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - updateMessageDeletedQuery = `UPDATE messages SET deleted = 1 WHERE mid = ?` - selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -268,7 +267,7 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message'); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); ` ) @@ -381,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error { } defer stmt.Close() for _, m := range ms { - if m.Event != messageEvent { + if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageReadEvent { return errUnexpectedMessageType } published := m.Time <= time.Now().Unix() @@ -431,7 +430,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - m.Deleted, + m.Event, ) if err != nil { return err @@ -720,8 +719,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string - var deleted bool + var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding, event string err := rows.Scan( &id, &sequenceID, @@ -744,7 +742,7 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, - &deleted, + &event, ) if err != nil { return nil, err @@ -782,7 +780,7 @@ func readMessage(rows *sql.Rows) (*message, error) { SequenceID: sequenceID, Time: timestamp, Expires: expires, - Event: messageEvent, + Event: event, Topic: topic, Message: msg, Title: title, @@ -796,7 +794,6 @@ func readMessage(rows *sql.Rows) (*message, error) { User: user, ContentType: contentType, Encoding: encoding, - Deleted: deleted, }, nil } diff --git a/server/server.go b/server/server.go index 635258fa..8aff7f7e 100644 --- a/server/server.go +++ b/server/server.go @@ -80,8 +80,9 @@ var ( wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - sidRegex = topicRegex updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) + markReadPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/read$`) + sequenceIDRegex = topicRegex webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -140,7 +141,6 @@ const ( firebaseControlTopic = "~control" // See Android if changed firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now) emptyMessageBody = "triggered" // Used when a message body is empty - deletedMessageBody = "deleted" // Used when a message is deleted newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages @@ -550,6 +550,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) + } else if r.Method == http.MethodPut && markReadPathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMarkRead))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -921,10 +923,8 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if e != nil { return e.With(t) } - // Create a delete message: empty body, same SequenceID, deleted flag set - m := newDefaultMessage(t.ID, deletedMessageBody) - m.SequenceID = sequenceID - m.Deleted = true + // Create a delete message with event type message_delete + m := newActionMessage(messageDeleteEvent, t.ID, sequenceID) m.Sender = v.IP() m.User = v.MaybeUserID() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() @@ -951,6 +951,50 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor return s.writeJSON(w, m) } +func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { + t, err := fromContext[*topic](r, contextTopic) + if err != nil { + return err + } + vrate, err := fromContext[*visitor](r, contextRateVisitor) + if err != nil { + return err + } + if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { + return errHTTPTooManyRequestsLimitMessages.With(t) + } + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) + if e != nil { + return e.With(t) + } + // Create a read message with event type message_read + m := newActionMessage(messageReadEvent, t.ID, sequenceID) + m.Sender = v.IP() + m.User = v.MaybeUserID() + m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() + // Publish to subscribers + if err := t.Publish(v, m); err != nil { + return err + } + // Send to Firebase for Android clients + if s.firebaseClient != nil { + go s.sendToFirebase(v, m) + } + // Send to web push endpoints + if s.config.WebPushPublicKey != "" { + go s.publishToWebPushEndpoints(v, m) + } + // Add to message cache + if err := s.messageCache.AddMessage(m); err != nil { + return err + } + logvrm(v, r, m).Tag(tagPublish).Debug("Marked message as read with sequence ID %s", sequenceID) + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return s.writeJSON(w, m) +} + func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { @@ -1017,10 +1061,10 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } else { sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid") if sequenceID != "" { - if sidRegex.MatchString(sequenceID) { + if sequenceIDRegex.MatchString(sequenceID) { m.SequenceID = sequenceID } else { - return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid + return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid } } else { m.SequenceID = m.ID @@ -1767,8 +1811,8 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { // sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") - if len(parts) != 3 { - return "", errHTTPBadRequestSIDInvalid + if len(parts) < 3 { + return "", errHTTPBadRequestSequenceIDInvalid } return parts[2], nil } diff --git a/server/types.go b/server/types.go index e9c0fdb8..5f9917d1 100644 --- a/server/types.go +++ b/server/types.go @@ -12,10 +12,12 @@ import ( // List of possible events const ( - openEvent = "open" - keepaliveEvent = "keepalive" - messageEvent = "message" - pollRequestEvent = "poll_request" + openEvent = "open" + keepaliveEvent = "keepalive" + messageEvent = "message" + messageDeleteEvent = "message_delete" + messageReadEvent = "message_read" + pollRequestEvent = "poll_request" ) const ( @@ -41,7 +43,6 @@ type message struct { PollID string `json:"poll_id,omitempty"` ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes - Deleted bool `json:"deleted,omitempty"` // True if message is marked as deleted Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments } @@ -149,6 +150,13 @@ func newPollRequestMessage(topic, pollID string) *message { return m } +// newActionMessage creates a new action message (message_delete or message_read) +func newActionMessage(event, topic, sequenceID string) *message { + m := newMessage(event, topic, "") + m.SequenceID = sequenceID + return m +} + func validMessageID(s string) bool { return util.ValidRandomString(s, messageIDLength) } @@ -227,7 +235,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { } func (q *queryFilter) Pass(msg *message) bool { - if msg.Event != messageEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { return false diff --git a/web/public/sw.js b/web/public/sw.js index 38dbc9c1..7d6441e3 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -9,6 +9,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { messageWithSequenceId } from "../src/app/utils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -62,11 +63,6 @@ const handlePushMessage = async (data) => { // Add notification to database await addNotification({ subscriptionId, message }); - // Don't show a notification for deleted messages - if (message.deleted) { - return; - } - // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); @@ -80,6 +76,51 @@ const handlePushMessage = async (data) => { ); }; +/** + * Handle a message_delete event: delete the notification from the database. + */ +const handlePushMessageDelete = async (data) => { + const { subscription_id: subscriptionId, message } = data; + const db = await dbAsync(); + + // Delete notification with the same sequence_id + const sequenceId = message.sequence_id; + if (sequenceId) { + console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId }); + await db.notifications.where({ subscriptionId, sequenceId }).delete(); + } + + // Update subscription last message id (for ?since=... queries) + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); +}; + +/** + * Handle a message_read event: mark the notification as read. + */ +const handlePushMessageRead = async (data) => { + const { subscription_id: subscriptionId, message } = data; + const db = await dbAsync(); + + // Mark notification as read (set new = 0) + const sequenceId = message.sequence_id; + if (sequenceId) { + console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId }); + await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); + } + + // Update subscription last message id (for ?since=... queries) + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); + + // Update badge count + const badgeCount = await db.notifications.where({ new: 1 }).count(); + console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); + self.navigator.setAppBadge?.(badgeCount); +}; + /** * Handle a received web push subscription expiring. */ @@ -114,8 +155,12 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - if (data.event === "message") { + if (data.event === EVENT_MESSAGE) { await handlePushMessage(data); + } else if (data.event === EVENT_MESSAGE_DELETE) { + await handlePushMessageDelete(data); + } else if (data.event === EVENT_MESSAGE_READ) { + await handlePushMessageRead(data); } else if (data.event === "subscription_expiring") { await handlePushSubscriptionExpiring(data); } else { diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 5358cdde..06043acc 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; +import { EVENT_OPEN, isNotificationEvent } from "./events"; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; @@ -48,10 +49,11 @@ class Connection { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); try { const data = JSON.parse(event.data); - if (data.event === "open") { + if (data.event === EVENT_OPEN) { return; } - const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data; + // Accept message, message_delete, and message_read events + const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data; if (!relevantAndValid) { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); return; diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index aa0e6dba..56415b76 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,6 +1,7 @@ import api from "./Api"; import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events"; const delayMillis = 2000; // 2 seconds const intervalMillis = 300000; // 5 minutes @@ -55,7 +56,7 @@ class Poller { // Delete all existing notifications for which the latest notification is marked as deleted const deletedSequenceIds = Object.entries(latestBySequenceId) - .filter(([, notification]) => notification.deleted) + .filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE) .map(([sequenceId]) => sequenceId); if (deletedSequenceIds.length > 0) { console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds); @@ -65,7 +66,9 @@ class Poller { } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => !n.deleted); + const notificationsToAdd = Object + .values(latestBySequenceId) + .filter(n => n.event === EVENT_MESSAGE); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 772b30a7..2eecde28 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -3,6 +3,7 @@ import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; import { messageWithSequenceId, topicUrl } from "./utils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "./events"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +16,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -48,7 +49,7 @@ class SubscriptionManager { } async notify(subscriptionId, notification) { - if (notification.deleted) { + if (notification.event !== EVENT_MESSAGE) { return; } const subscription = await this.get(subscriptionId); @@ -83,7 +84,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -101,7 +102,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -174,7 +175,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists || notification.deleted) { + if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_READ) { return false; } try { @@ -185,13 +186,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSequenceId(notification), subscriptionId, - new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -207,7 +208,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -250,19 +251,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 7e3c47e3..cb65c0b6 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -13,7 +13,7 @@ const createDatabase = (username) => { db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sequenceId,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sequenceId]", + notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", prefs: "&key" }); diff --git a/web/src/app/events.js b/web/src/app/events.js new file mode 100644 index 00000000..55dc262c --- /dev/null +++ b/web/src/app/events.js @@ -0,0 +1,14 @@ +// Event types for ntfy messages +// These correspond to the server event types in server/types.go + +export const EVENT_OPEN = "open"; +export const EVENT_KEEPALIVE = "keepalive"; +export const EVENT_MESSAGE = "message"; +export const EVENT_MESSAGE_DELETE = "message_delete"; +export const EVENT_MESSAGE_READ = "message_read"; +export const EVENT_POLL_REQUEST = "poll_request"; + +// Check if an event is a notification event (message, delete, or read) +export const isNotificationEvent = (event) => + event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_READ; + diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 5b50f0a8..5e271b35 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -12,6 +12,7 @@ import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; +import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../app/events"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -53,13 +54,18 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() // and FirebaseService::handleMessage(). - // Delete existing notification with same sequenceId, if any - const sequenceId = notification.sequence_id || notification.id; - if (sequenceId) { - await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); - } - // Add notification to database - if (!notification.deleted) { + if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { + // Handle delete: remove notification from database + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); + } else if (notification.event === EVENT_MESSAGE_READ && notification.sequence_id) { + // Handle read: mark notification as read + await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); + } else { + // Regular message: delete existing and add new + const sequenceId = notification.sequence_id || notification.id; + if (sequenceId) { + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); + } const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification); From 37d71051de90ee02c978ff17e26fe7da7fb49e0e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 20:57:55 -0500 Subject: [PATCH 17/33] Refine --- server/server.go | 75 +++++++++++++--------------------------- server/server_webpush.go | 2 +- server/types.go | 11 ++++++ 3 files changed, 36 insertions(+), 52 deletions(-) diff --git a/server/server.go b/server/server.go index 8aff7f7e..e111434d 100644 --- a/server/server.go +++ b/server/server.go @@ -879,7 +879,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -908,50 +908,14 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - t, err := fromContext[*topic](r, contextTopic) - if err != nil { - return err - } - vrate, err := fromContext[*visitor](r, contextRateVisitor) - if err != nil { - return err - } - if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { - return errHTTPTooManyRequestsLimitMessages.With(t) - } - sequenceID, e := s.sequenceIDFromPath(r.URL.Path) - if e != nil { - return e.With(t) - } - // Create a delete message with event type message_delete - m := newActionMessage(messageDeleteEvent, t.ID, sequenceID) - m.Sender = v.IP() - m.User = v.MaybeUserID() - m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() - // Publish to subscribers - if err := t.Publish(v, m); err != nil { - return err - } - // Send to Firebase for Android clients - if s.firebaseClient != nil { - go s.sendToFirebase(v, m) - } - // Send to web push endpoints - if s.config.WebPushPublicKey != "" { - go s.publishToWebPushEndpoints(v, m) - } - // Add to message cache - if err := s.messageCache.AddMessage(m); err != nil { - return err - } - logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with sequence ID %s", sequenceID) - s.mu.Lock() - s.messages++ - s.mu.Unlock() - return s.writeJSON(w, m) + return s.handleActionMessage(w, r, v, messageDeleteEvent, s.sequenceIDFromPath) } func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { + return s.handleActionMessage(w, r, v, messageReadEvent, s.sequenceIDFromMarkReadPath) +} + +func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string, extractSequenceID func(string) (string, *errHTTP)) error { t, err := fromContext[*topic](r, contextTopic) if err != nil { return err @@ -963,12 +927,12 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visit if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return errHTTPTooManyRequestsLimitMessages.With(t) } - sequenceID, e := s.sequenceIDFromPath(r.URL.Path) + sequenceID, e := extractSequenceID(r.URL.Path) if e != nil { return e.With(t) } - // Create a read message with event type message_read - m := newActionMessage(messageReadEvent, t.ID, sequenceID) + // Create an action message with the given event type + m := newActionMessage(event, t.ID, sequenceID) m.Sender = v.IP() m.User = v.MaybeUserID() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() @@ -988,11 +952,11 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visit if err := s.messageCache.AddMessage(m); err != nil { return err } - logvrm(v, r, m).Tag(tagPublish).Debug("Marked message as read with sequence ID %s", sequenceID) + logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID) s.mu.Lock() s.messages++ s.mu.Unlock() - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) sendToFirebase(v *visitor, m *message) { @@ -1384,7 +1348,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } return buf.String(), nil @@ -1395,10 +1359,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } - if msg.Event != messageEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this! } return fmt.Sprintf("data: %s\n", buf.String()), nil @@ -1808,7 +1772,7 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } -// sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere +// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") if len(parts) < 3 { @@ -1817,6 +1781,15 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { return parts[2], nil } +// sequenceIDFromMarkReadPath returns the sequence ID from a path like /mytopic/sequenceIdHere/read +func (s *Server) sequenceIDFromMarkReadPath(path string) (string, *errHTTP) { + parts := strings.Split(path, "/") + if len(parts) < 4 || parts[3] != "read" { + return "", errHTTPBadRequestSequenceIDInvalid + } + return parts[2], nil +} + // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() diff --git a/server/server_webpush.go b/server/server_webpush.go index 526e06f2..d3f09bd9 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { return } log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions)) - payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m)) + payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON())) if err != nil { log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload") return diff --git a/server/types.go b/server/types.go index 5f9917d1..ec682133 100644 --- a/server/types.go +++ b/server/types.go @@ -65,6 +65,17 @@ func (m *message) Context() log.Context { return fields } +// forJSON returns a copy of the message suitable for JSON output. +// It clears the SequenceID if it equals the ID to reduce redundancy. +func (m *message) forJSON() *message { + if m.SequenceID == m.ID { + clone := *m + clone.SequenceID = "" + return &clone + } + return m +} + type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` From a3c16d81f88109196b1ffee9af71fe228c676490 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 13 Jan 2026 16:31:13 -0500 Subject: [PATCH 18/33] Rename to clear --- go.sum | 39 +++++++++++++++++++++++++++--- server/message_cache.go | 8 +++--- server/server.go | 27 +++++++-------------- server/types.go | 8 +++--- web/public/sw.js | 10 ++++---- web/src/app/Connection.js | 2 +- web/src/app/Poller.js | 4 +-- web/src/app/SubscriptionManager.js | 22 ++++++++--------- web/src/app/db.js | 2 +- web/src/app/events.js | 6 ++--- web/src/components/hooks.js | 4 +-- 11 files changed, 76 insertions(+), 56 deletions(-) diff --git a/go.sum b/go.sum index 0915f1cb..241b5556 100644 --- a/go.sum +++ b/go.sum @@ -6,20 +6,22 @@ cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute v1.53.0 h1:dILGanjePNsYfZVYYv6K0d4+IPnKX1gn84Fk8jDPNvs= -cloud.google.com/go/compute v1.53.0/go.mod h1:zdogTa7daHhEtEX92+S5IARtQmi/RNVPUfoI8Jhl8Do= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= @@ -30,6 +32,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= @@ -47,14 +51,19 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI= github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -79,6 +88,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -91,6 +104,14 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -104,6 +125,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= @@ -113,13 +135,17 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= @@ -138,6 +164,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -146,6 +174,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -231,9 +261,10 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4= @@ -249,6 +280,8 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/message_cache.go b/server/message_cache.go index 396cd7a2..ec1a395e 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -117,9 +117,9 @@ const ( WHERE time <= ? AND published = 0 ORDER BY time, id ` - selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -380,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error { } defer stmt.Close() for _, m := range ms { - if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageReadEvent { + if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent { return errUnexpectedMessageType } published := m.Time <= time.Now().Unix() diff --git a/server/server.go b/server/server.go index e111434d..7ef43275 100644 --- a/server/server.go +++ b/server/server.go @@ -81,7 +81,7 @@ var ( authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) - markReadPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/read$`) + clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) sequenceIDRegex = topicRegex webConfigPath = "/config.js" @@ -550,8 +550,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) - } else if r.Method == http.MethodPut && markReadPathRegex.MatchString(r.URL.Path) { - return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMarkRead))(w, r, v) + } else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -908,14 +908,14 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.handleActionMessage(w, r, v, messageDeleteEvent, s.sequenceIDFromPath) + return s.handleActionMessage(w, r, v, messageDeleteEvent) } -func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.handleActionMessage(w, r, v, messageReadEvent, s.sequenceIDFromMarkReadPath) +func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error { + return s.handleActionMessage(w, r, v, messageClearEvent) } -func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string, extractSequenceID func(string) (string, *errHTTP)) error { +func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error { t, err := fromContext[*topic](r, contextTopic) if err != nil { return err @@ -927,7 +927,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return errHTTPTooManyRequestsLimitMessages.With(t) } - sequenceID, e := extractSequenceID(r.URL.Path) + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) if e != nil { return e.With(t) } @@ -1362,7 +1362,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } - if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this! } return fmt.Sprintf("data: %s\n", buf.String()), nil @@ -1781,15 +1781,6 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { return parts[2], nil } -// sequenceIDFromMarkReadPath returns the sequence ID from a path like /mytopic/sequenceIdHere/read -func (s *Server) sequenceIDFromMarkReadPath(path string) (string, *errHTTP) { - parts := strings.Split(path, "/") - if len(parts) < 4 || parts[3] != "read" { - return "", errHTTPBadRequestSequenceIDInvalid - } - return parts[2], nil -} - // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() diff --git a/server/types.go b/server/types.go index ec682133..6464222f 100644 --- a/server/types.go +++ b/server/types.go @@ -16,7 +16,7 @@ const ( keepaliveEvent = "keepalive" messageEvent = "message" messageDeleteEvent = "message_delete" - messageReadEvent = "message_read" + messageClearEvent = "message_clear" pollRequestEvent = "poll_request" ) @@ -33,7 +33,7 @@ type message struct { Event string `json:"event"` // One of the above Topic string `json:"topic"` Title string `json:"title,omitempty"` - Message string `json:"message"` // Allow empty message body + Message string `json:"message,omitempty"` Priority int `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` Click string `json:"click,omitempty"` @@ -161,7 +161,7 @@ func newPollRequestMessage(topic, pollID string) *message { return m } -// newActionMessage creates a new action message (message_delete or message_read) +// newActionMessage creates a new action message (message_delete or message_clear) func newActionMessage(event, topic, sequenceID string) *message { m := newMessage(event, topic, "") m.SequenceID = sequenceID @@ -246,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { } func (q *queryFilter) Pass(msg *message) bool { - if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { return false diff --git a/web/public/sw.js b/web/public/sw.js index 7d6441e3..0dc1afef 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -9,7 +9,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { messageWithSequenceId } from "../src/app/utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/app/events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -97,9 +97,9 @@ const handlePushMessageDelete = async (data) => { }; /** - * Handle a message_read event: mark the notification as read. + * Handle a message_clear event: clear/dismiss the notification. */ -const handlePushMessageRead = async (data) => { +const handlePushMessageClear = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); @@ -159,8 +159,8 @@ const handlePush = async (data) => { await handlePushMessage(data); } else if (data.event === EVENT_MESSAGE_DELETE) { await handlePushMessageDelete(data); - } else if (data.event === EVENT_MESSAGE_READ) { - await handlePushMessageRead(data); + } else if (data.event === EVENT_MESSAGE_CLEAR) { + await handlePushMessageClear(data); } else if (data.event === "subscription_expiring") { await handlePushSubscriptionExpiring(data); } else { diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 06043acc..8e02d6f7 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -52,7 +52,7 @@ class Connection { if (data.event === EVENT_OPEN) { return; } - // Accept message, message_delete, and message_read events + // Accept message, message_delete, and message_clear events const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data; if (!relevantAndValid) { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 56415b76..b455a308 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -66,9 +66,7 @@ class Poller { } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object - .values(latestBySequenceId) - .filter(n => n.event === EVENT_MESSAGE); + const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 2eecde28..41eeba2f 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -3,7 +3,7 @@ import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; import { messageWithSequenceId, topicUrl } from "./utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "./events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "./events"; class SubscriptionManager { constructor(dbImpl) { @@ -16,7 +16,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } @@ -84,7 +84,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null + last: null, }; await this.db.subscriptions.put(subscription); @@ -102,7 +102,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation // May be null! + reservation, // May be null! }); return local.id; @@ -175,7 +175,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_READ) { + if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) { return false; } try { @@ -186,13 +186,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSequenceId(notification), subscriptionId, - new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id + last: notification.id, }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -208,7 +208,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId + last: lastNotificationId, }); } @@ -251,19 +251,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName + displayName, }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation + reservation, }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index cb65c0b6..172857d9 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -15,7 +15,7 @@ const createDatabase = (username) => { subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", - prefs: "&key" + prefs: "&key", }); return db; diff --git a/web/src/app/events.js b/web/src/app/events.js index 55dc262c..48537652 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -5,10 +5,8 @@ export const EVENT_OPEN = "open"; export const EVENT_KEEPALIVE = "keepalive"; export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; -export const EVENT_MESSAGE_READ = "message_read"; +export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; // Check if an event is a notification event (message, delete, or read) -export const isNotificationEvent = (event) => - event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_READ; - +export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 5e271b35..2d88f2cf 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -12,7 +12,7 @@ import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; -import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../app/events"; +import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -57,7 +57,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { // Handle delete: remove notification from database await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); - } else if (notification.event === EVENT_MESSAGE_READ && notification.sequence_id) { + } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { // Handle read: mark notification as read await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); } else { From 44f20f6b4c367a7ad6e7709ff35d819c3e93c771 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 13 Jan 2026 20:50:31 -0500 Subject: [PATCH 19/33] Add tests, fix firebase --- server/server_firebase.go | 10 ++ server/server_firebase_test.go | 3 + server/server_test.go | 210 ++++++++++++++++++++++++++++- web/src/app/SubscriptionManager.js | 7 +- web/src/app/notificationUtils.js | 2 +- web/src/app/utils.js | 6 +- 6 files changed, 229 insertions(+), 9 deletions(-) diff --git a/server/server_firebase.go b/server/server_firebase.go index 13e80b93..9fde63a3 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -143,6 +143,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro "poll_id": m.PollID, } apnsConfig = createAPNSAlertConfig(m, data) + case messageDeleteEvent, messageClearEvent: + data = map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + "sequence_id": m.SequenceID, + } + apnsConfig = createAPNSBackgroundConfig(data) case messageEvent: if auther != nil { // If "anonymous read" for a topic is not allowed, we cannot send the message along @@ -161,6 +170,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro "time": fmt.Sprintf("%d", m.Time), "event": m.Event, "topic": m.Topic, + "sequence_id": m.SequenceID, "priority": fmt.Sprintf("%d", m.Priority), "tags": strings.Join(m.Tags, ","), "click": m.Click, diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index 89004cd3..c98f528f 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -177,6 +177,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "time": fmt.Sprintf("%d", m.Time), "event": "message", "topic": "mytopic", + "sequence_id": "", "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", @@ -199,6 +200,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "time": fmt.Sprintf("%d", m.Time), "event": "message", "topic": "mytopic", + "sequence_id": "", "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", @@ -232,6 +234,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { "time": fmt.Sprintf("%d", m.Time), "event": "poll_request", "topic": "mytopic", + "sequence_id": "", "message": "New message", "title": "", "tags": "", diff --git a/server/server_test.go b/server/server_test.go index 964d6156..530d9458 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -8,8 +8,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/user" "io" "net/http" "net/http/httptest" @@ -24,7 +22,9 @@ import ( "time" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -3289,6 +3289,212 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) { require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") } +func TestServer_DeleteMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", msg.SequenceID) + require.Equal(t, "message", msg.Event) + + // Delete the message using DELETE method + response = request(t, s, "DELETE", "/mytopic/seq123", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_delete", msg2.Event) + require.Equal(t, "seq123", msg1.SequenceID) + require.Equal(t, "seq123", msg2.SequenceID) +} + +func TestServer_ClearMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", msg.SequenceID) + require.Equal(t, "message", msg.Event) + + // Clear the message using PUT /topic/seq/clear + response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil) + require.Equal(t, 200, response.Code) + clearMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", clearMsg.SequenceID) + require.Equal(t, "message_clear", clearMsg.Event) + + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_clear", msg2.Event) + require.Equal(t, "seq456", msg1.SequenceID) + require.Equal(t, "seq456", msg2.SequenceID) +} + +func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { + // Test that /topic/seq/read also works + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a message + response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil) + require.Equal(t, 200, response.Code) + + // Clear using /read endpoint + response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil) + require.Equal(t, 200, response.Code) + clearMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq789", clearMsg.SequenceID) + require.Equal(t, "message_clear", clearMsg.Event) +} + +func TestServer_UpdateMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish original message + response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "update-seq", msg1.SequenceID) + require.Equal(t, "original message", msg1.Message) + + // Update the message (same sequence ID, new content) + response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "update-seq", msg2.SequenceID) + require.Equal(t, "updated message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs + + // Poll and verify both versions are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + polledMsg1 := toMessage(t, lines[0]) + polledMsg2 := toMessage(t, lines[1]) + require.Equal(t, "original message", polledMsg1.Message) + require.Equal(t, "updated message", polledMsg2.Message) + require.Equal(t, "update-seq", polledMsg1.SequenceID) + require.Equal(t, "update-seq", polledMsg2.SequenceID) +} + +func TestServer_UpdateMessage_UsingMessageID(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish original message without a sequence ID + response := request(t, s, "PUT", "/mytopic", "original message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Empty(t, msg1.SequenceID) // No sequence ID provided + require.Equal(t, "original message", msg1.Message) + + // Update the message using the message ID as the sequence ID + response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID + require.Equal(t, "updated message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs + + // Poll and verify both versions are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + polledMsg1 := toMessage(t, lines[0]) + polledMsg2 := toMessage(t, lines[1]) + require.Equal(t, "original message", polledMsg1.Message) + require.Equal(t, "updated message", polledMsg2.Message) + require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID + require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID +} + +func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Test invalid sequence ID for delete (returns 404 because route doesn't match) + response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil) + require.Equal(t, 404, response.Code) + + // Test invalid sequence ID for clear (returns 404 because route doesn't match) + response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil) + require.Equal(t, 404, response.Code) +} + +func TestServer_DeleteMessage_WithFirebase(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + + // Publish a message + response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 1, len(sender.Messages())) + require.Equal(t, "message", sender.Messages()[0].Data["event"]) + + // Delete the message + response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 2, len(sender.Messages())) + require.Equal(t, "message_delete", sender.Messages()[1].Data["event"]) + require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"]) +} + +func TestServer_ClearMessage_WithFirebase(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + + // Publish a message + response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) + require.Equal(t, 1, len(sender.Messages())) + + // Clear the message + response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) + require.Equal(t, 2, len(sender.Messages())) + require.Equal(t, "message_clear", sender.Messages()[1].Data["event"]) + require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"]) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 41eeba2f..430c5e2c 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -202,9 +202,10 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications.map((notification) => { - return { ...messageWithSequenceId(notification), subscriptionId }; - }); + const notificationsWithSubscriptionId = notifications.map((notification) => ({ + ...messageWithSequenceId(notification), + subscriptionId, + })); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 65b5bd3d..2d80e0be 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -50,7 +50,7 @@ export const isImage = (attachment) => { export const icon = "/static/images/ntfy.png"; export const badge = "/static/images/mask-icon.svg"; -export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { +export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9aeada05..9e095c7e 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -104,10 +104,10 @@ export const maybeActionErrors = (notification) => { }; export const messageWithSequenceId = (message) => { - if (!message.sequenceId) { - message.sequenceId = message.sequence_id || message.id; + if (message.sequenceId) { + return message; } - return message; + return { ...message, sequenceId: message.sequence_id || message.id }; }; export const shuffle = (arr) => { From dd9b36cf0a1ff1a048579deabad046d20ba10c74 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 13 Jan 2026 21:29:44 -0500 Subject: [PATCH 20/33] Fix db crash --- web/src/app/db.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/app/db.js b/web/src/app/db.js index 172857d9..e088a267 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -18,6 +18,13 @@ const createDatabase = (username) => { prefs: "&key", }); + // When another connection (e.g., service worker or another tab) wants to upgrade, + // close this connection gracefully to allow the upgrade to proceed + db.on("versionchange", () => { + console.log("[db] versionchange event: closing database"); + db.close(); + }); + return db; }; From 96638b516c3c1ee05b7f760523ab3518ca2a3c34 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 20:46:18 -0500 Subject: [PATCH 21/33] Fix service worker handling for updating/deleting --- server/server.go | 6 ++-- web/public/sw.js | 52 +++++++++++++----------------- web/src/app/SubscriptionManager.js | 5 +-- web/src/app/events.js | 1 + web/src/app/notificationUtils.js | 13 ++++++-- web/src/app/utils.js | 7 ---- web/src/registerSW.js | 22 +++++++++++-- 7 files changed, 59 insertions(+), 47 deletions(-) diff --git a/server/server.go b/server/server.go index 7ef43275..3bd53ea6 100644 --- a/server/server.go +++ b/server/server.go @@ -86,8 +86,6 @@ var ( webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" - webRootHTMLPath = "/app.html" - webServiceWorkerPath = "/sw.js" accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" @@ -111,7 +109,7 @@ var ( apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}" apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`) apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`) - staticRegex = regexp.MustCompile(`^/static/.+`) + staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) @@ -534,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { return s.handleMetrics(w, r, v) - } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) { + } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleDocs)(w, r, v) diff --git a/web/public/sw.js b/web/public/sw.js index 0dc1afef..07a89415 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -3,13 +3,10 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from import { NavigationRoute, registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { clientsClaim } from "workbox-core"; - import { dbAsync } from "../src/app/db"; - -import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; +import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { messageWithSequenceId } from "../src/app/utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src/app/events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, EVENT_SUBSCRIPTION_EXPIRING } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -23,10 +20,17 @@ import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src const broadcastChannel = new BroadcastChannel("web-push-broadcast"); -const addNotification = async ({ subscriptionId, message }) => { +/** + * Handle a received web push message and show notification. + * + * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) + * receives the broadcast and plays a sound (see web/src/app/WebPush.js). + */ +const handlePushMessage = async (data) => { + const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); - // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + console.log("[ServiceWorker] Message received", data); // Delete existing notification with same sequence ID (if any) const sequenceId = message.sequence_id || message.id; @@ -46,22 +50,9 @@ const addNotification = async ({ subscriptionId, message }) => { last: message.id, }); + // Update badge in PWA const badgeCount = await db.notifications.where({ new: 1 }).count(); - console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); self.navigator.setAppBadge?.(badgeCount); -}; - -/** - * Handle a received web push message and show notification. - * - * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) - * receives the broadcast and plays a sound (see web/src/app/WebPush.js). - */ -const handlePushMessage = async (data) => { - const { subscription_id: subscriptionId, message } = data; - - // Add notification to database - await addNotification({ subscriptionId, message }); // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); @@ -82,11 +73,11 @@ const handlePushMessage = async (data) => { const handlePushMessageDelete = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); + console.log("[ServiceWorker] Deleting notification sequence", data); // Delete notification with the same sequence_id const sequenceId = message.sequence_id; if (sequenceId) { - console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId }); await db.notifications.where({ subscriptionId, sequenceId }).delete(); } @@ -102,11 +93,11 @@ const handlePushMessageDelete = async (data) => { const handlePushMessageClear = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); + console.log("[ServiceWorker] Marking notification as read", data); // Mark notification as read (set new = 0) const sequenceId = message.sequence_id; if (sequenceId) { - console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId }); await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } @@ -126,6 +117,7 @@ const handlePushMessageClear = async (data) => { */ const handlePushSubscriptionExpiring = async (data) => { const t = await initI18n(); + console.log("[ServiceWorker] Handling incoming subscription expiring event", data); await self.registration.showNotification(t("web_push_subscription_expiring_title"), { body: t("web_push_subscription_expiring_body"), @@ -141,6 +133,7 @@ const handlePushSubscriptionExpiring = async (data) => { */ const handlePushUnknown = async (data) => { const t = await initI18n(); + console.log("[ServiceWorker] Unknown event received", data); await self.registration.showNotification(t("web_push_unknown_notification_title"), { body: t("web_push_unknown_notification_body"), @@ -155,13 +148,15 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - if (data.event === EVENT_MESSAGE) { + const { message } = data; + + if (message.event === EVENT_MESSAGE) { await handlePushMessage(data); - } else if (data.event === EVENT_MESSAGE_DELETE) { + } else if (message.event === EVENT_MESSAGE_DELETE) { await handlePushMessageDelete(data); - } else if (data.event === EVENT_MESSAGE_CLEAR) { + } else if (message.event === EVENT_MESSAGE_CLEAR) { await handlePushMessageClear(data); - } else if (data.event === "subscription_expiring") { + } else if (message.event === EVENT_SUBSCRIPTION_EXPIRING) { await handlePushSubscriptionExpiring(data); } else { await handlePushUnknown(data); @@ -176,10 +171,8 @@ const handleClick = async (event) => { const t = await initI18n(); const clients = await self.clients.matchAll({ type: "window" }); - const rootUrl = new URL(self.location.origin); const rootClient = clients.find((client) => client.url === rootUrl.toString()); - // perhaps open on another topic const fallbackClient = clients[0]; if (!event.notification.data?.message) { @@ -295,6 +288,7 @@ precacheAndRoute( // Claim all open windows clientsClaim(); + // Delete any cached old dist files from previous service worker versions cleanupOutdatedCaches(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 430c5e2c..f909778e 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,8 +2,9 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { messageWithSequenceId, topicUrl } from "./utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "./events"; +import { topicUrl } from "./utils"; +import { messageWithSequenceId } from "./notificationUtils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events"; class SubscriptionManager { constructor(dbImpl) { diff --git a/web/src/app/events.js b/web/src/app/events.js index 48537652..94d7dc79 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -7,6 +7,7 @@ export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; +export const EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; // Check if an event is a notification event (message, delete, or read) export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 2d80e0be..a9b8f8ff 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => { export const formatMessage = (m) => { if (m.title) { - return m.message; + return m.message || ""; } const emojiList = toEmojis(m.tags); if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; + return `${emojiList.join(" ")} ${m.message || ""}`; } - return m.message; + return m.message || ""; }; const imageRegex = /\.(png|jpe?g|gif|webp)$/i; @@ -79,3 +79,10 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { }, ]; }; + +export const messageWithSequenceId = (message) => { + if (message.sequenceId) { + return message; + } + return { ...message, sequenceId: message.sequence_id || message.id }; +}; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9e095c7e..935f2024 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,13 +103,6 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; -export const messageWithSequenceId = (message) => { - if (message.sequenceId) { - return message; - } - return { ...message, sequenceId: message.sequence_id || message.id }; -}; - export const shuffle = (arr) => { const returnArr = [...arr]; diff --git a/web/src/registerSW.js b/web/src/registerSW.js index adef4746..5ae85628 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -5,10 +5,21 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register"; const intervalMS = 60 * 60 * 1000; // https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html -const registerSW = () => +const registerSW = () => { + console.log("[ServiceWorker] Registering service worker"); + console.log("[ServiceWorker] serviceWorker in navigator:", "serviceWorker" in navigator); + + if (!("serviceWorker" in navigator)) { + console.warn("[ServiceWorker] Service workers not supported"); + return; + } + viteRegisterSW({ onRegisteredSW(swUrl, registration) { + console.log("[ServiceWorker] Registered:", { swUrl, registration }); + if (!registration) { + console.warn("[ServiceWorker] No registration returned"); return; } @@ -23,9 +34,16 @@ const registerSW = () => }, }); - if (resp?.status === 200) await registration.update(); + if (resp?.status === 200) { + console.log("[ServiceWorker] Updating service worker"); + await registration.update(); + } }, intervalMS); }, + onRegisterError(error) { + console.error("[ServiceWorker] Registration error:", error); + }, }); +}; export default registerSW; From ff2b8167b4d663b7bd2ed2348a6b8db2803b960f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 21:17:21 -0500 Subject: [PATCH 22/33] Make closing notifications work --- web/public/sw.js | 15 ++++++++++++++- web/src/app/Notifier.js | 15 +++++++++++++++ web/src/components/hooks.js | 4 ++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index 07a89415..db87e7f8 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -81,6 +81,13 @@ const handlePushMessageDelete = async (data) => { 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()); + } + // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { last: message.id, @@ -101,6 +108,13 @@ const handlePushMessageClear = async (data) => { 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()); + } + // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { last: message.id, @@ -108,7 +122,6 @@ const handlePushMessageClear = async (data) => { // Update badge count const badgeCount = await db.notifications.where({ new: 1 }).count(); - console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); self.navigator.setAppBadge?.(badgeCount); }; diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 77bbdb1e..79089749 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -31,6 +31,21 @@ class Notifier { ); } + async cancel(notification) { + if (!this.supported()) { + return; + } + try { + const tag = notification.sequence_id || notification.id; + console.log(`[Notifier] Cancelling notification with ${tag}`); + const registration = await this.serviceWorkerRegistration(); + const notifications = await registration.getNotifications({ tag }); + notifications.forEach((notification) => notification.close()); + } catch (e) { + console.log(`[Notifier] Error cancelling notification`, e); + } + } + async playSound() { // Play sound const sound = await prefs.sound(); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 2d88f2cf..1c9c2bff 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -55,11 +55,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // and FirebaseService::handleMessage(). if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { - // Handle delete: remove notification from database await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); + await notifier.cancel(notification); } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { - // Handle read: mark notification as read await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); + await notifier.cancel(notification); } else { // Regular message: delete existing and add new const sequenceId = notification.sequence_id || notification.id; From 2e39a1329cdf7e30a4abff0a23ac523a6d09820d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 21:48:21 -0500 Subject: [PATCH 23/33] AI docs, needs work --- docs/faq.md | 4 +- docs/publish.md | 161 ++++++++++++++++++++++++++++++++++++---- docs/releases.md | 10 +++ docs/subscribe/api.md | 29 ++++---- web/src/app/Notifier.js | 6 +- 5 files changed, 175 insertions(+), 35 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 5fa5252c..5153c700 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -96,8 +96,8 @@ appreciated. ## Can I email you? Can I DM you on Discord/Matrix? For community support, please use the public channels listed on the [contact page](contact.md). I generally **do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing) -plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business -opportunities (see [general inquiries](contact.md#general-inquiries)). +plan (see [paid support](contact.md#paid-support)), or you are inquiring about business +opportunities (see [other inquiries](contact.md#other-inquiries)). I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions in public forums benefits others, since I can link to the discussion at a later point in time, or other users diff --git a/docs/publish.md b/docs/publish.md index 9c409523..fb0b38fb 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1418,22 +1418,23 @@ The JSON message format closely mirrors the format of the message you can consum (see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of all the supported fields: -| Field | Required | Type | Example | Description | -|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------| -| `topic` | ✔️ | *string* | `topic1` | Target topic name | -| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | -| `title` | - | *string* | `Some title` | Message [title](#message-title) | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | -| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | -| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | -| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | -| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | -| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | -| `filename` | - | *string* | `file.jpg` | File name of the attachment | -| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | -| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | -| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | +| Field | Required | Type | Example | Description | +|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------| +| `topic` | ✔️ | *string* | `topic1` | Target topic name | +| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | +| `title` | - | *string* | `Some title` | Message [title](#message-title) | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | +| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | +| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | +| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | +| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | +| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | +| `filename` | - | *string* | `file.jpg` | File name of the attachment | +| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | +| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | +| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | +| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | ## Action buttons _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -2694,6 +2695,134 @@ Here's an example that will open Reddit when the notification is clicked: ])); ``` +## Updating + deleting notifications +_Supported on:_ :material-android: :material-firefox: + +You can update, clear (mark as read), or delete notifications that have already been delivered. This is useful for scenarios +like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. + +The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as +belonging to the same sequence, and clients will update/replace the notification accordingly. + +### Updating notifications +To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous +notification with the new one. + +You can either: + +1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier + +=== "Using the message ID" + ```bash + # First, publish a message and capture the message ID + $ curl -d "Downloading file..." ntfy.sh/mytopic + {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + ``` + +=== "Using a custom sequence ID" + ```bash + # Publish with a custom sequence ID + $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + + # Update using the same sequence ID + $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + ``` + +=== "Using the X-Sequence-ID header" + ```bash + # Publish with a sequence ID via header + $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic + + # Update using the same sequence ID + $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic + ``` + +You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the +`sequence_id` field. + +### Clearing notifications +To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to +`///clear` (or `///read` as an alias): + +=== "Command line (curl)" + ```bash + curl -X PUT ntfy.sh/mytopic/my-download-123/clear + ``` + +=== "HTTP" + ```http + PUT /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_clear` event, which tells clients to: + +- Mark the notification as read in the app +- Dismiss the browser/Android notification + +### Deleting notifications +To delete a notification entirely, send a DELETE request to `//`: + +=== "Command line (curl)" + ```bash + curl -X DELETE ntfy.sh/mytopic/my-download-123 + ``` + +=== "HTTP" + ```http + DELETE /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_delete` event, which tells clients to: + +- Delete the notification from the database +- Dismiss the browser/Android notification + +!!! info + Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will + reappear as a new message. + +### Full example +Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion: + +```bash +# 1. Create a notification with a custom sequence ID +$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024 + +# 2. Update the notification with progress +$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024 + +# 3. Update again when complete +$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024 + +# 4. Clear the notification (dismiss from notification drawer) +$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear + +# 5. Later, delete the notification entirely +$ curl -X DELETE ntfy.sh/mytopic/backup-2024 +``` + +When polling the topic, you'll see the complete sequence of events: + +```bash +$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq . +``` + +```json +{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."} +{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."} +{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"} +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"} +{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"} +``` + +Clients process these events in order, keeping only the latest state for each sequence ID. + ## Attachments _Supported on:_ :material-android: :material-firefox: diff --git a/docs/releases.md b/docs/releases.md index 20759f15..1d80a039 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1599,12 +1599,22 @@ 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): You can now update, + clear (mark as read), or delete notifications using a sequence ID. This enables use cases like progress updates, + replacing outdated notifications, or dismissing notifications from all clients. + ### ntfy Android app v1.22.x (UNRELEASED) **Features:** * Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) * Connection error dialog to help diagnose connection issues +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): Notifications with + the same sequence ID are updated in place, and `message_delete`/`message_clear` events dismiss notifications **Bug fixes + maintenance:** diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index a52e17f6..a549885b 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -324,20 +324,21 @@ format of the message. It's very straight forward: **Message**: -| Field | Required | Type | Example | Description | -|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | -| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | -| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | -| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | -| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | -| `message` | - | *string* | `Some message` | Message body; always present in `message` events | -| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | -| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | -| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | -| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | +| Field | Required | Type | Example | Description | +|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | +| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | +| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | +| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` | +| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | +| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) | +| `message` | - | *string* | `Some message` | Message body; always present in `message` events | +| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | +| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | +| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | +| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 79089749..723ec43d 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -26,7 +26,7 @@ class Notifier { subscriptionId: subscription.id, message: notification, defaultTitle, - topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), + topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString() }) ); } @@ -40,7 +40,7 @@ class Notifier { console.log(`[Notifier] Cancelling notification with ${tag}`); const registration = await this.serviceWorkerRegistration(); const notifications = await registration.getNotifications({ tag }); - notifications.forEach((notification) => notification.close()); + notifications.forEach(n => n.close()); } catch (e) { console.log(`[Notifier] Error cancelling notification`, e); } @@ -72,7 +72,7 @@ class Notifier { if (hasWebPushTopics) { return pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlB64ToUint8Array(config.web_push_public_key), + applicationServerKey: urlB64ToUint8Array(config.web_push_public_key) }); } From ea3008c7071491f0dbeb3a10f497c53111c45e2e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 22:18:57 -0500 Subject: [PATCH 24/33] Move, add screenshots --- docs/publish.md | 264 +++++++++--------- docs/static/css/extra.css | 18 +- ...droid-screenshot-notification-update-1.png | Bin 0 -> 82085 bytes ...droid-screenshot-notification-update-2.png | Bin 0 -> 81609 bytes 4 files changed, 145 insertions(+), 137 deletions(-) create mode 100644 docs/static/img/android-screenshot-notification-update-1.png create mode 100644 docs/static/img/android-screenshot-notification-update-2.png diff --git a/docs/publish.md b/docs/publish.md index fb0b38fb..6d5dca26 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -937,6 +937,142 @@ Here's an example with a custom message, tags and a priority: file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); ``` +## Updating + deleting notifications +_Supported on:_ :material-android: :material-firefox: + +!!! info + **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. + +You can **update, clear, or delete notifications** that have already been delivered. This is useful for scenarios +like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. + +The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as +belonging to the same sequence, and clients will update/replace the notification accordingly. + +
+ + +
+ +### Updating notifications +To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous +notification with the new one. + +You can either: + +1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier + +=== "Using the message ID" + ```bash + # First, publish a message and capture the message ID + $ curl -d "Downloading file..." ntfy.sh/mytopic + {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + ``` + +=== "Using a custom sequence ID" + ```bash + # Publish with a custom sequence ID + $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + + # Update using the same sequence ID + $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + ``` + +=== "Using the X-Sequence-ID header" + ```bash + # Publish with a sequence ID via header + $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic + + # Update using the same sequence ID + $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic + ``` + +You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the +`sequence_id` field. + +### Clearing notifications +To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to +`///clear` (or `///read` as an alias): + +=== "Command line (curl)" + ```bash + curl -X PUT ntfy.sh/mytopic/my-download-123/clear + ``` + +=== "HTTP" + ```http + PUT /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_clear` event, which tells clients to: + +- Mark the notification as read in the app +- Dismiss the browser/Android notification + +### Deleting notifications +To delete a notification entirely, send a DELETE request to `//`: + +=== "Command line (curl)" + ```bash + curl -X DELETE ntfy.sh/mytopic/my-download-123 + ``` + +=== "HTTP" + ```http + DELETE /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_delete` event, which tells clients to: + +- Delete the notification from the database +- Dismiss the browser/Android notification + +!!! info + Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will + reappear as a new message. + +### Full example +Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion: + +```bash +# 1. Create a notification with a custom sequence ID +$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024 + +# 2. Update the notification with progress +$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024 + +# 3. Update again when complete +$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024 + +# 4. Clear the notification (dismiss from notification drawer) +$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear + +# 5. Later, delete the notification entirely +$ curl -X DELETE ntfy.sh/mytopic/backup-2024 +``` + +When polling the topic, you'll see the complete sequence of events: + +```bash +$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq . +``` + +```json +{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."} +{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."} +{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"} +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"} +{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"} +``` + +Clients process these events in order, keeping only the latest state for each sequence ID. + ## Message templating _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -2695,134 +2831,6 @@ Here's an example that will open Reddit when the notification is clicked: ])); ``` -## Updating + deleting notifications -_Supported on:_ :material-android: :material-firefox: - -You can update, clear (mark as read), or delete notifications that have already been delivered. This is useful for scenarios -like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. - -The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as -belonging to the same sequence, and clients will update/replace the notification accordingly. - -### Updating notifications -To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous -notification with the new one. - -You can either: - -1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates -2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier - -=== "Using the message ID" - ```bash - # First, publish a message and capture the message ID - $ curl -d "Downloading file..." ntfy.sh/mytopic - {"id":"xE73Iyuabi","time":1673542291,...} - - # Then use the message ID to update it - $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi - ``` - -=== "Using a custom sequence ID" - ```bash - # Publish with a custom sequence ID - $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 - - # Update using the same sequence ID - $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 - ``` - -=== "Using the X-Sequence-ID header" - ```bash - # Publish with a sequence ID via header - $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic - - # Update using the same sequence ID - $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic - ``` - -You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the -`sequence_id` field. - -### Clearing notifications -To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to -`///clear` (or `///read` as an alias): - -=== "Command line (curl)" - ```bash - curl -X PUT ntfy.sh/mytopic/my-download-123/clear - ``` - -=== "HTTP" - ```http - PUT /mytopic/my-download-123/clear HTTP/1.1 - Host: ntfy.sh - ``` - -This publishes a `message_clear` event, which tells clients to: - -- Mark the notification as read in the app -- Dismiss the browser/Android notification - -### Deleting notifications -To delete a notification entirely, send a DELETE request to `//`: - -=== "Command line (curl)" - ```bash - curl -X DELETE ntfy.sh/mytopic/my-download-123 - ``` - -=== "HTTP" - ```http - DELETE /mytopic/my-download-123 HTTP/1.1 - Host: ntfy.sh - ``` - -This publishes a `message_delete` event, which tells clients to: - -- Delete the notification from the database -- Dismiss the browser/Android notification - -!!! info - Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will - reappear as a new message. - -### Full example -Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion: - -```bash -# 1. Create a notification with a custom sequence ID -$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024 - -# 2. Update the notification with progress -$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024 - -# 3. Update again when complete -$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024 - -# 4. Clear the notification (dismiss from notification drawer) -$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear - -# 5. Later, delete the notification entirely -$ curl -X DELETE ntfy.sh/mytopic/backup-2024 -``` - -When polling the topic, you'll see the complete sequence of events: - -```bash -$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq . -``` - -```json -{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."} -{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."} -{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"} -{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"} -{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"} -``` - -Clients process these events in order, keeping only the latest state for each sequence ID. - ## Attachments _Supported on:_ :material-android: :material-firefox: diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index 3c53aed6..4577ccce 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -1,10 +1,10 @@ :root > * { - --md-primary-fg-color: #338574; + --md-primary-fg-color: #338574; --md-primary-fg-color--light: #338574; - --md-primary-fg-color--dark: #338574; - --md-footer-bg-color: #353744; - --md-text-font: "Roboto"; - --md-code-font: "Roboto Mono"; + --md-primary-fg-color--dark: #338574; + --md-footer-bg-color: #353744; + --md-text-font: "Roboto"; + --md-code-font: "Roboto Mono"; } .md-header__button.md-logo :is(img, svg) { @@ -34,7 +34,7 @@ figure img, figure video { } header { - background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); + background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%); } body[data-md-color-scheme="default"] header { @@ -93,7 +93,7 @@ figure video { .screenshots img { max-height: 230px; - max-width: 300px; + max-width: 350px; margin: 3px; border-radius: 5px; filter: drop-shadow(2px 2px 2px #ddd); @@ -107,7 +107,7 @@ figure video { opacity: 0; visibility: hidden; position: fixed; - left:0; + left: 0; right: 0; top: 0; bottom: 0; @@ -119,7 +119,7 @@ figure video { } .lightbox.show { - background-color: rgba(0,0,0, 0.75); + background-color: rgba(0, 0, 0, 0.75); opacity: 1; visibility: visible; z-index: 1000; diff --git a/docs/static/img/android-screenshot-notification-update-1.png b/docs/static/img/android-screenshot-notification-update-1.png new file mode 100644 index 0000000000000000000000000000000000000000..16320de4c4e83752b4cb16da0f14e1d929c00852 GIT binary patch literal 82085 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

    {9WD`1bWDJg_I6BUrvjPvt4k4kP^n3r@vy5d7ol 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 L%FxnR#zFrN;V5PV literal 0 HcmV?d00001 From 3d54260f7932024f99bee94ccfb6828629a51d55 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 09:30:37 -0500 Subject: [PATCH 25/33] Docs --- client/options.go | 5 ++ cmd/publish.go | 6 ++ docs/publish.md | 159 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 150 insertions(+), 20 deletions(-) diff --git a/client/options.go b/client/options.go index f4711834..b99f1673 100644 --- a/client/options.go +++ b/client/options.go @@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) } +// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications +func WithSequenceID(sequenceID string) PublishOption { + return WithHeader("X-Sequence-ID", sequenceID) +} + // WithEmail instructs the server to also send the message to the given e-mail address func WithEmail(email string) PublishOption { return WithHeader("X-Email", email) diff --git a/cmd/publish.go b/cmd/publish.go index f3139a63..c80c140b 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -34,6 +34,7 @@ var flagsPublish = append( &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, &cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"}, &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, + &cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"}, &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, @@ -70,6 +71,7 @@ Examples: ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment + ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates echo 'message' | ntfy publish mytopic # Send message from stdin ntfy pub -u phil:mypass secret Psst # Publish with username/password ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing @@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error { markdown := c.Bool("markdown") template := c.String("template") filename := c.String("filename") + sequenceID := c.String("sequence-id") file := c.String("file") email := c.String("email") user := c.String("user") @@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error { if filename != "" { options = append(options, client.WithFilename(filename)) } + if sequenceID != "" { + options = append(options, client.WithSequenceID(sequenceID)) + } if email != "" { options = append(options, client.WithEmail(email)) } diff --git a/docs/publish.md b/docs/publish.md index 6d5dca26..b8afb092 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -963,36 +963,95 @@ You can either: 1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates 2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier -=== "Using the message ID" - ```bash - # First, publish a message and capture the message ID - $ curl -d "Downloading file..." ntfy.sh/mytopic - {"id":"xE73Iyuabi","time":1673542291,...} +Here's an example using a custom sequence ID to update a notification: - # Then use the message ID to update it - $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi - ``` - -=== "Using a custom sequence ID" +=== "Command line (curl)" ```bash # Publish with a custom sequence ID - $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 # Update using the same sequence ID - $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 ``` -=== "Using the X-Sequence-ID header" +=== "ntfy CLI" ```bash - # Publish with a sequence ID via header - $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic + # Publish with a sequence ID + ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..." # Update using the same sequence ID - $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic + ntfy pub --sequence-id=my-download-123 mytopic "Download complete!" ``` -You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the -`sequence_id` field. +=== "HTTP" + ``` http + POST /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + + Downloading file... + ``` + +=== "JavaScript" + ``` javascript + // First message + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'POST', + body: 'Downloading file...' + }); + + // Update with same sequence ID + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'POST', + body: 'Download complete!' + }); + ``` + +=== "Go" + ``` go + // Publish with sequence ID in URL path + http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", + strings.NewReader("Downloading file...")) + + // Update with same sequence ID + http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", + strings.NewReader("Download complete!")) + ``` + +=== "PowerShell" + ``` powershell + # Publish with sequence ID + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..." + + # Update with same sequence ID + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download complete!" + ``` + +=== "Python" + ``` python + import requests + + # Publish with sequence ID + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...") + + # Update with same sequence ID + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download complete!") + ``` + +=== "PHP" + ``` php-inline + // Publish with sequence ID + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] + ])); + + // Update with same sequence ID + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + ])); + ``` + +You can also set the sequence ID via the `X-Sequence-ID` header, the `sid` query parameter, or when +[publishing as JSON](#publish-as-json) using the `sequence_id` field. ### Clearing notifications To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to @@ -1004,11 +1063,41 @@ To clear a notification (mark it as read and dismiss it from the notification dr ``` === "HTTP" - ```http + ``` http PUT /mytopic/my-download-123/clear HTTP/1.1 Host: ntfy.sh ``` +=== "JavaScript" + ``` javascript + await fetch('https://ntfy.sh/mytopic/my-download-123/clear', { + method: 'PUT' + }); + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear" + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/mytopic/my-download-123/clear") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([ + 'http' => ['method' => 'PUT'] + ])); + ``` + This publishes a `message_clear` event, which tells clients to: - Mark the notification as read in the app @@ -1023,11 +1112,41 @@ To delete a notification entirely, send a DELETE request to `// ['method' => 'DELETE'] + ])); + ``` + This publishes a `message_delete` event, which tells clients to: - Delete the notification from the database From db4a4776d3639d696011b907645112aa37903db7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 10:07:05 -0500 Subject: [PATCH 26/33] Reword --- docs/publish.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index b8afb092..55788dcd 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -946,7 +946,11 @@ _Supported on:_ :material-android: :material-firefox: You can **update, clear, or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. -The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as +The way this works is that when you publish a message with a specific **sequence ID**, clients that receive the +notification will treat it as part of a sequence. When a new message with the same sequence ID is published, clients +will update the existing notification instead of creating a new one. You can also + +The key concept is the **sequence ID**: notifications with the same sequence ID are treated as belonging to the same sequence, and clients will update/replace the notification accordingly.
    @@ -963,7 +967,87 @@ You can either: 1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates 2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier -Here's an example using a custom sequence ID to update a notification: +If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned +message `id` to update it. The message ID of the first message will then serve as the sequence ID for subsequent updates: + +=== "Command line (curl)" + ```bash + # First, publish a message and capture the message ID + curl -d "Downloading file..." ntfy.sh/mytopic + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + ``` + +=== "ntfy CLI" + ```bash + # First, publish a message and capture the message ID + ntfy pub mytopic "Downloading file..." + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + ntfy pub --sequence-id=xE73Iyuabi mytopic "Download complete!" + ``` + +=== "JavaScript" + ``` javascript + // First, publish and get the message ID + const response = await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + body: 'Downloading file...' + }); + const { id } = await response.json(); + + // Then use the message ID to update + await fetch(`https://ntfy.sh/mytopic/${id}`, { + method: 'POST', + body: 'Download complete!' + }); + ``` + +=== "Go" + ``` go + // Publish and parse the response to get the message ID + resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain", + strings.NewReader("Downloading file...")) + var msg struct { ID string `json:"id"` } + json.NewDecoder(resp.Body).Decode(&msg) + + // Update using the message ID + http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain", + strings.NewReader("Download complete!")) + ``` + +=== "Python" + ``` python + import requests + + # Publish and get the message ID + response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...") + message_id = response.json()["id"] + + # Update using the message ID + requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download complete!") + ``` + +=== "PHP" + ``` php-inline + // Publish and get the message ID + $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] + ])); + $messageId = json_decode($response)->id; + + // Update using the message ID + file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + ])); + ``` + +You can also use a custom sequence ID (e.g., a download ID, job ID, etc.) when publishing the first message. This is +less cumbersome, since you don't need to capture the message ID first. Just publish directly to +`//`: === "Command line (curl)" ```bash From e81be48bf302a6b2e40d47a77f370f8009cb48b5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 10:32:48 -0500 Subject: [PATCH 27/33] MOre docs update --- docs/publish.md | 183 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 148 insertions(+), 35 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 55788dcd..0652fbd8 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -943,16 +943,17 @@ _Supported on:_ :material-android: :material-firefox: !!! info **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. -You can **update, clear, or delete notifications** that have already been delivered. This is useful for scenarios +You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. +* Updating notifications will alter the content of an existing notification. +* Clearing notifications will mark them as read and dismiss them from the notification drawer. +* Deleting notifications will remove them from the notification drawer and remove them in the clients as well (if supported). + The way this works is that when you publish a message with a specific **sequence ID**, clients that receive the notification will treat it as part of a sequence. When a new message with the same sequence ID is published, clients will update the existing notification instead of creating a new one. You can also -The key concept is the **sequence ID**: notifications with the same sequence ID are treated as -belonging to the same sequence, and clients will update/replace the notification accordingly. -
    @@ -960,12 +961,11 @@ belonging to the same sequence, and clients will update/replace the notification ### Updating notifications To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous -notification with the new one. +notification with the new one. You can either: -You can either: - -1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates -2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier +1. **Use the message ID**: First publish like normal to `POST /` without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `POST //` with your own identifier, or use `POST /` with the + `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`) If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned message `id` to update it. The message ID of the first message will then serve as the sequence ID for subsequent updates: @@ -976,8 +976,11 @@ message `id` to update it. The message ID of the first message will then serve a curl -d "Downloading file..." ntfy.sh/mytopic # Returns: {"id":"xE73Iyuabi","time":1673542291,...} - # Then use the message ID to update it - curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + # Then use the message ID to update it (via URL path) + curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi + + # Or update using the X-Sequence-ID header + curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic ``` === "ntfy CLI" @@ -987,7 +990,34 @@ message `id` to update it. The message ID of the first message will then serve a # Returns: {"id":"xE73Iyuabi","time":1673542291,...} # Then use the message ID to update it - ntfy pub --sequence-id=xE73Iyuabi mytopic "Download complete!" + ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..." + + # Update again with the same sequence ID + ntfy pub -S xE73Iyuabi mytopic "Download complete" + ``` + +=== "HTTP" + ``` http + # First, publish a message and capture the message ID + POST /mytopic HTTP/1.1 + Host: ntfy.sh + + Downloading file... + + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + POST /mytopic/xE73Iyuabi HTTP/1.1 + Host: ntfy.sh + + Download 50% ... + + # Update again with the same sequence ID, this time using the header + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Sequence-ID: xE73Iyuabi + + Download complete ``` === "JavaScript" @@ -999,10 +1029,17 @@ message `id` to update it. The message ID of the first message will then serve a }); const { id } = await response.json(); - // Then use the message ID to update + // Update via URL path await fetch(`https://ntfy.sh/mytopic/${id}`, { method: 'POST', - body: 'Download complete!' + body: 'Download 50% ...' + }); + + // Or update using the X-Sequence-ID header + await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + headers: { 'X-Sequence-ID': id }, + body: 'Download complete' }); ``` @@ -1014,9 +1051,29 @@ message `id` to update it. The message ID of the first message will then serve a var msg struct { ID string `json:"id"` } json.NewDecoder(resp.Body).Decode(&msg) - // Update using the message ID + // Update via URL path http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain", - strings.NewReader("Download complete!")) + strings.NewReader("Download 50% ...")) + + // Or update using the X-Sequence-ID header + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Download complete")) + req.Header.Set("X-Sequence-ID", msg.ID) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Publish and get the message ID + $response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..." + $messageId = $response.id + + # Update via URL path + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..." + + # Or update using the X-Sequence-ID header + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` + -Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete" ``` === "Python" @@ -1027,8 +1084,12 @@ message `id` to update it. The message ID of the first message will then serve a response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...") message_id = response.json()["id"] - # Update using the message ID - requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download complete!") + # Update via URL path + requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...") + + # Or update using the X-Sequence-ID header + requests.post("https://ntfy.sh/mytopic", + headers={"X-Sequence-ID": message_id}, data="Download complete") ``` === "PHP" @@ -1039,9 +1100,18 @@ message `id` to update it. The message ID of the first message will then serve a ])); $messageId = json_decode($response)->id; - // Update using the message ID + // Update via URL path file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] + ])); + + // Or update using the X-Sequence-ID header + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "X-Sequence-ID: $messageId", + 'content' => 'Download complete' + ] ])); ``` @@ -1054,8 +1124,11 @@ less cumbersome, since you don't need to capture the message ID first. Just publ # Publish with a custom sequence ID curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 - # Update using the same sequence ID - curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + # Update using the same sequence ID (via URL path) + curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123 + + # Or update using the X-Sequence-ID header + curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic ``` === "ntfy CLI" @@ -1064,7 +1137,10 @@ less cumbersome, since you don't need to capture the message ID first. Just publ ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..." # Update using the same sequence ID - ntfy pub --sequence-id=my-download-123 mytopic "Download complete!" + ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..." + + # Update again + ntfy pub -S my-download-123 mytopic "Download complete" ``` === "HTTP" @@ -1074,6 +1150,13 @@ less cumbersome, since you don't need to capture the message ID first. Just publ Downloading file... ``` + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Sequence-ID: my-download-123 + + Download complete + ``` === "JavaScript" ``` javascript @@ -1083,10 +1166,17 @@ less cumbersome, since you don't need to capture the message ID first. Just publ body: 'Downloading file...' }); - // Update with same sequence ID + // Update via URL path await fetch('https://ntfy.sh/mytopic/my-download-123', { method: 'POST', - body: 'Download complete!' + body: 'Download 50% ...' + }); + + // Or update using the X-Sequence-ID header + await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + headers: { 'X-Sequence-ID': 'my-download-123' }, + body: 'Download complete' }); ``` @@ -1096,9 +1186,15 @@ less cumbersome, since you don't need to capture the message ID first. Just publ http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", strings.NewReader("Downloading file...")) - // Update with same sequence ID + // Update via URL path http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", - strings.NewReader("Download complete!")) + strings.NewReader("Download 50% ...")) + + // Or update using the X-Sequence-ID header + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Download complete")) + req.Header.Set("X-Sequence-ID", "my-download-123") + http.DefaultClient.Do(req) ``` === "PowerShell" @@ -1106,8 +1202,12 @@ less cumbersome, since you don't need to capture the message ID first. Just publ # Publish with sequence ID Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..." - # Update with same sequence ID - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download complete!" + # Update via URL path + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..." + + # Or update using the X-Sequence-ID header + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` + -Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete" ``` === "Python" @@ -1117,8 +1217,12 @@ less cumbersome, since you don't need to capture the message ID first. Just publ # Publish with sequence ID requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...") - # Update with same sequence ID - requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download complete!") + # Update via URL path + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...") + + # Or update using the X-Sequence-ID header + requests.post("https://ntfy.sh/mytopic", + headers={"X-Sequence-ID": "my-download-123"}, data="Download complete") ``` === "PHP" @@ -1128,14 +1232,23 @@ less cumbersome, since you don't need to capture the message ID first. Just publ 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] ])); - // Update with same sequence ID + // Update via URL path file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] + ])); + + // Or update using the X-Sequence-ID header + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => 'X-Sequence-ID: my-download-123', + 'content' => 'Download complete' + ] ])); ``` -You can also set the sequence ID via the `X-Sequence-ID` header, the `sid` query parameter, or when -[publishing as JSON](#publish-as-json) using the `sequence_id` field. +You can also set the sequence ID via the `sid` query parameter, or when [publishing as JSON](#publish-as-json) +using the `sequence_id` field. ### Clearing notifications To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to From 94eb121f38f79d6d4a09c5e259d0c6eac6225f03 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 13:07:07 -0500 Subject: [PATCH 28/33] Docs updates --- docs/publish.md | 104 +++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 0652fbd8..3363063a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -946,19 +946,24 @@ _Supported on:_ :material-android: :material-firefox: You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. -* Updating notifications will alter the content of an existing notification. -* Clearing notifications will mark them as read and dismiss them from the notification drawer. -* Deleting notifications will remove them from the notification drawer and remove them in the clients as well (if supported). +* [Updating notifications](#updating-notifications) will alter the content of an existing notification. +* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer. +* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported). -The way this works is that when you publish a message with a specific **sequence ID**, clients that receive the -notification will treat it as part of a sequence. When a new message with the same sequence ID is published, clients -will update the existing notification instead of creating a new one. You can also +Here's an example of a download progress notification being updated over time on Android:
    +To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence, +using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the +same sequence ID and the clients will perform the appropriate action on the existing notification. + +Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates +the update, clear, or delete action. This append-only behavior ensures that message history remains intact. + ### Updating notifications To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous notification with the new one. You can either: @@ -968,7 +973,7 @@ notification with the new one. You can either: `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`) If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned -message `id` to update it. The message ID of the first message will then serve as the sequence ID for subsequent updates: +message `id` to update it. Here's an example: === "Command line (curl)" ```bash @@ -1115,8 +1120,8 @@ message `id` to update it. The message ID of the first message will then serve a ])); ``` -You can also use a custom sequence ID (e.g., a download ID, job ID, etc.) when publishing the first message. This is -less cumbersome, since you don't need to capture the message ID first. Just publish directly to +You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message. +**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to `//`: === "Command line (curl)" @@ -1145,12 +1150,13 @@ less cumbersome, since you don't need to capture the message ID first. Just publ === "HTTP" ``` http + # Publish a message with a custom sequence ID POST /mytopic/my-download-123 HTTP/1.1 Host: ntfy.sh Downloading file... - ``` - ``` http + + # Update again using the X-Sequence-ID header POST /mytopic HTTP/1.1 Host: ntfy.sh X-Sequence-ID: my-download-123 @@ -1247,12 +1253,24 @@ less cumbersome, since you don't need to capture the message ID first. Just publ ])); ``` -You can also set the sequence ID via the `sid` query parameter, or when [publishing as JSON](#publish-as-json) -using the `sequence_id` field. +You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when +[publishing as JSON](#publish-as-json) using the `sequence_id` field. + +If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id` +field the response. A sequence of updates may look like this (first example from above): + +```json +{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."} +{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."} +{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"} +``` ### Clearing notifications -To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to -`///clear` (or `///read` as an alias): +Clearing a notification means **marking it as read and dismissing it from the notification drawer**. + +To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias). +This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status +and dismiss the notification. === "Command line (curl)" ```bash @@ -1295,13 +1313,17 @@ To clear a notification (mark it as read and dismiss it from the notification dr ])); ``` -This publishes a `message_clear` event, which tells clients to: +An example response from the server with the `message_clear` event may look like this: -- Mark the notification as read in the app -- Dismiss the browser/Android notification +```json +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"} +``` ### Deleting notifications -To delete a notification entirely, send a DELETE request to `//`: +Deleting a notification means **removing it from the notification drawer and from the client's database**. + +To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event +that is used by the clients (web app and Android app) to remove the notification entirely. === "Command line (curl)" ```bash @@ -1344,51 +1366,16 @@ To delete a notification entirely, send a DELETE request to `// Date: Thu, 15 Jan 2026 19:06:05 -0500 Subject: [PATCH 29/33] Refine, docs --- web/public/sw.js | 6 ++++++ web/src/app/Notifier.js | 6 +++--- web/src/components/hooks.js | 7 +++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index db87e7f8..7affd55f 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -163,6 +163,12 @@ const handlePushUnknown = async (data) => { const handlePush = async (data) => { const { message } = data; + // This logic is (partially) duplicated in + // - Android: SubscriberService::onNotificationReceived() + // - Android: FirebaseService::onMessageReceived() + // - Web app: hooks.js:handleNotification() + // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... + if (message.event === EVENT_MESSAGE) { await handlePushMessage(data); } else if (message.event === EVENT_MESSAGE_DELETE) { diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 723ec43d..f6e47a7c 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -26,7 +26,7 @@ class Notifier { subscriptionId: subscription.id, message: notification, defaultTitle, - topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString() + topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), }) ); } @@ -40,7 +40,7 @@ class Notifier { console.log(`[Notifier] Cancelling notification with ${tag}`); const registration = await this.serviceWorkerRegistration(); const notifications = await registration.getNotifications({ tag }); - notifications.forEach(n => n.close()); + notifications.forEach((n) => n.close()); } catch (e) { console.log(`[Notifier] Error cancelling notification`, e); } @@ -72,7 +72,7 @@ class Notifier { if (hasWebPushTopics) { return pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlB64ToUint8Array(config.web_push_public_key) + applicationServerKey: urlB64ToUint8Array(config.web_push_public_key), }); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 1c9c2bff..9dadd551 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -51,8 +51,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { - // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() - // and FirebaseService::handleMessage(). + // This logic is (partially) duplicated in + // - Android: SubscriberService::onNotificationReceived() + // - Android: FirebaseService::onMessageReceived() + // - Web app: hooks.js:handleNotification() + // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); From 2cc4bf7d28ba07cf747c28ace78ef1b7a3e944fa Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 21:55:20 -0500 Subject: [PATCH 30/33] Fix webpush stuff --- docs/releases.md | 22 ++++++++++++++-------- server/errors.go | 5 +++-- web/public/sw.js | 33 ++++++++++++++++++++------------- web/src/app/events.js | 4 +++- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 1d80a039..2f3f669e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1603,22 +1603,28 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): You can now update, - clear (mark as read), or delete notifications using a sequence ID. This enables use cases like progress updates, - replacing outdated notifications, or dismissing notifications from all clients. +* 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 app v1.22.x (UNRELEASED) **Features:** -* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) +* 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 self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215), + [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149), + thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing) * Connection error dialog to help diagnose connection issues -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): Notifications with - the same sequence ID are updated in place, and `message_delete`/`message_clear` events dismiss notifications **Bug fixes + maintenance:** -* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting) -* Fix crash in sharing dialog (thanks to @rogeliodh) +* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529), + thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing) +* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh)) * Fix crash when exiting multi-delete in detail view * Fix potential crashes with icon downloader and backuper diff --git a/server/errors.go b/server/errors.go index e8f58d75..a29ff27d 100644 --- a/server/errors.go +++ b/server/errors.go @@ -3,8 +3,9 @@ package server import ( "encoding/json" "fmt" - "heckel.io/ntfy/v2/log" "net/http" + + "heckel.io/ntfy/v2/log" ) // errHTTP is a generic HTTP error for any non-200 HTTP error @@ -125,7 +126,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/web/public/sw.js b/web/public/sw.js index 7affd55f..6b298414 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -6,7 +6,13 @@ import { clientsClaim } from "workbox-core"; import { dbAsync } from "../src/app/db"; import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, EVENT_SUBSCRIPTION_EXPIRING } from "../src/app/events"; +import { + EVENT_MESSAGE, + EVENT_MESSAGE_CLEAR, + EVENT_MESSAGE_DELETE, + WEBPUSH_EVENT_MESSAGE, + WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING, +} from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -161,25 +167,26 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - const { message } = data; - // This logic is (partially) duplicated in // - Android: SubscriberService::onNotificationReceived() // - Android: FirebaseService::onMessageReceived() // - Web app: hooks.js:handleNotification() // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... - if (message.event === EVENT_MESSAGE) { - await handlePushMessage(data); - } else if (message.event === EVENT_MESSAGE_DELETE) { - await handlePushMessageDelete(data); - } else if (message.event === EVENT_MESSAGE_CLEAR) { - await handlePushMessageClear(data); - } else if (message.event === EVENT_SUBSCRIPTION_EXPIRING) { - await handlePushSubscriptionExpiring(data); - } else { - await handlePushUnknown(data); + if (data.event === WEBPUSH_EVENT_MESSAGE) { + const { message } = data; + if (message.event === EVENT_MESSAGE) { + return await handlePushMessage(data); + } else if (message.event === EVENT_MESSAGE_DELETE) { + return await handlePushMessageDelete(data); + } else if (message.event === EVENT_MESSAGE_CLEAR) { + return await handlePushMessageClear(data); + } + } else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) { + return await handlePushSubscriptionExpiring(data); } + + return await handlePushUnknown(data); }; /** diff --git a/web/src/app/events.js b/web/src/app/events.js index 94d7dc79..d5c5ab88 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -7,7 +7,9 @@ export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; -export const EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; + +export const WEBPUSH_EVENT_MESSAGE = "message"; +export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; // Check if an event is a notification event (message, delete, or read) export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; From 0ad06e808ffd8e071a6cae252138f49e1d000c20 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 22:14:56 -0500 Subject: [PATCH 31/33] Self-review --- web/src/registerSW.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/registerSW.js b/web/src/registerSW.js index 5ae85628..842cf80e 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -7,8 +7,6 @@ const intervalMS = 60 * 60 * 1000; // https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html const registerSW = () => { console.log("[ServiceWorker] Registering service worker"); - console.log("[ServiceWorker] serviceWorker in navigator:", "serviceWorker" in navigator); - if (!("serviceWorker" in navigator)) { console.warn("[ServiceWorker] Service workers not supported"); return; From fcf57a04e1651702fef521d452aad47431645c29 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 16 Jan 2026 09:36:27 -0500 Subject: [PATCH 32/33] Move event up --- server/message_cache.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index ec1a395e..c0c1ea78 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -31,6 +31,7 @@ const ( mid TEXT NOT NULL, sequence_id TEXT NOT NULL, time INT NOT NULL, + event TEXT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -50,8 +51,7 @@ const ( user TEXT NOT NULL, content_type TEXT NOT NULL, encoding TEXT NOT NULL, - published INT NOT NULL, - event TEXT NOT NULL + published INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); @@ -69,50 +69,50 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, event) + 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 = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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 time <= ? AND published = 0 ORDER BY time, id @@ -410,6 +410,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ID, m.SequenceID, m.Time, + m.Event, m.Expires, m.Topic, m.Message, @@ -430,7 +431,6 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - m.Event, ) if err != nil { return err @@ -719,11 +719,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding, event string + var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, &sequenceID, ×tamp, + &event, &expires, &topic, &msg, @@ -742,7 +743,6 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, - &event, ) if err != nil { return nil, err From c1ee163cabeec2bfa41e5c139c3b4429a7db1cdf Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 16 Jan 2026 10:07:09 -0500 Subject: [PATCH 33/33] Remove cache thing --- server/message_cache.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index c0c1ea78..342f9687 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -771,10 +771,6 @@ func readMessage(rows *sql.Rows) (*message, error) { URL: attachmentURL, } } - // Clear SequenceID if it equals ID (we do not want the SequenceID in the message output) - if sequenceID == id { - sequenceID = "" - } return &message{ ID: id, SequenceID: sequenceID,