Rename to sequence_id

This commit is contained in:
binwiederhier
2026-01-08 14:27:18 -05:00
parent fd8cd5ca91
commit 1ab7ca876c
13 changed files with 125 additions and 121 deletions

View File

@@ -125,7 +125,7 @@ var (
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} 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} 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} 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} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

View File

@@ -29,7 +29,7 @@ const (
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL, mid TEXT NOT NULL,
sid TEXT NOT NULL, sequence_id TEXT NOT NULL,
time INT NOT NULL, time INT NOT NULL,
expires INT NOT NULL, expires INT NOT NULL,
topic TEXT NOT NULL, topic TEXT NOT NULL,
@@ -54,7 +54,7 @@ const (
deleted INT NOT NULL deleted INT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); 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_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); 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_expires ON messages (expires);
@@ -69,50 +69,50 @@ const (
COMMIT; COMMIT;
` `
insertMessageQuery = ` 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics 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 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 FROM messages
WHERE mid = ? WHERE mid = ?
` `
selectMessagesSinceTimeQuery = ` 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 FROM messages
WHERE topic = ? AND time >= ? AND published = 1 WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceTimeIncludeScheduledQuery = ` 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 FROM messages
WHERE topic = ? AND time >= ? WHERE topic = ? AND time >= ?
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceIDQuery = ` 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 FROM messages
WHERE topic = ? AND id > ? AND published = 1 WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id ORDER BY time, id
` `
selectMessagesSinceIDIncludeScheduledQuery = ` 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 FROM messages
WHERE topic = ? AND (id > ? OR published = 0) WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id ORDER BY time, id
` `
selectMessagesLatestQuery = ` 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 FROM messages
WHERE topic = ? AND published = 1 WHERE topic = ? AND published = 1
ORDER BY time DESC, id DESC ORDER BY time DESC, id DESC
LIMIT 1 LIMIT 1
` `
selectMessagesDueQuery = ` 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 FROM messages
WHERE time <= ? AND published = 0 WHERE time <= ? AND published = 0
ORDER BY time, id ORDER BY time, id
@@ -267,9 +267,9 @@ const (
//13 -> 14 //13 -> 14
migrate13To14AlterMessagesTableQuery = ` 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'); 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( _, err := stmt.Exec(
m.ID, m.ID,
m.SID, m.SequenceID,
m.Time, m.Time,
m.Expires, m.Expires,
m.Topic, m.Topic,
@@ -720,11 +720,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
func readMessage(rows *sql.Rows) (*message, error) { func readMessage(rows *sql.Rows) (*message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64 var timestamp, expires, attachmentSize, attachmentExpires int64
var priority int 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 var deleted bool
err := rows.Scan( err := rows.Scan(
&id, &id,
&sid, &sequenceID,
&timestamp, &timestamp,
&expires, &expires,
&topic, &topic,
@@ -773,13 +773,13 @@ func readMessage(rows *sql.Rows) (*message, error) {
URL: attachmentURL, URL: attachmentURL,
} }
} }
// Clear SID if it equals ID (we do not want the SID in the message output) // Clear SequenceID if it equals ID (we do not want the SequenceID in the message output)
if sid == id { if sequenceID == id {
sid = "" sequenceID = ""
} }
return &message{ return &message{
ID: id, ID: id,
SID: sid, SequenceID: sequenceID,
Time: timestamp, Time: timestamp,
Expires: expires, Expires: expires,
Event: messageEvent, Event: messageEvent,

View File

@@ -319,7 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := newDefaultMessage("mytopic", "flower for you") m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1" m.ID = "m1"
m.SID = "m1" m.SequenceID = "m1"
m.Sender = netip.MustParseAddr("1.2.3.4") m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "flower.jpg", Name: "flower.jpg",
@@ -333,7 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car") m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2" m.ID = "m2"
m.SID = "m2" m.SequenceID = "m2"
m.Sender = netip.MustParseAddr("1.2.3.4") m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "car.jpg", Name: "car.jpg",
@@ -347,7 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car") m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3" m.ID = "m3"
m.SID = "m3" m.SequenceID = "m3"
m.User = "u_BAsbaAa" m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8") m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &attachment{ m.Attachment = &attachment{
@@ -403,13 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) {
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m := newDefaultMessage("mytopic", "flower for you") m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1" m.ID = "m1"
m.SID = "m1" m.SequenceID = "m1"
m.Expires = time.Now().Add(time.Hour).Unix() m.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(m)) require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic", "message with attachment") m = newDefaultMessage("mytopic", "message with attachment")
m.ID = "m2" m.ID = "m2"
m.SID = "m2" m.SequenceID = "m2"
m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "car.jpg", Name: "car.jpg",
@@ -422,7 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic", "message with external attachment") m = newDefaultMessage("mytopic", "message with external attachment")
m.ID = "m3" m.ID = "m3"
m.SID = "m3" m.SequenceID = "m3"
m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "car.jpg", Name: "car.jpg",
@@ -434,7 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic2", "message with expired attachment") m = newDefaultMessage("mytopic2", "message with expired attachment")
m.ID = "m4" m.ID = "m4"
m.SID = "m4" m.SequenceID = "m4"
m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "expired-car.jpg", Name: "expired-car.jpg",

View File

@@ -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() { if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return errHTTPTooManyRequestsLimitMessages.With(t) return errHTTPTooManyRequestsLimitMessages.With(t)
} }
sid, e := s.sidFromPath(r.URL.Path) sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
if e != nil { if e != nil {
return e.With(t) 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 := newDefaultMessage(t.ID, deletedMessageBody)
m.SID = sid m.SequenceID = sequenceID
m.Deleted = true m.Deleted = true
m.Sender = v.IP() m.Sender = v.IP()
m.User = v.MaybeUserID() 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 { if err := s.messageCache.AddMessage(m); err != nil {
return err 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.mu.Lock()
s.messages++ s.messages++
s.mu.Unlock() 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) { 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) { 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 { if err != nil {
return false, false, "", "", "", false, err return false, false, "", "", "", false, err
} }
m.SID = pathSID m.SequenceID = pathSequenceID
} else { } else {
sid := readParam(r, "x-sequence-id", "sequence-id", "sid") sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
if sid != "" { if sequenceID != "" {
if sidRegex.MatchString(sid) { if sidRegex.MatchString(sequenceID) {
m.SID = sid m.SequenceID = sequenceID
} else { } else {
return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid
} }
} else { } else {
m.SID = m.ID m.SequenceID = m.ID
} }
} }
cache = readBoolParam(r, true, "x-cache", "cache") 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 return topics, parts[1], nil
} }
// sidFromPath returns the SID from a POST path like /mytopic/sidHere // sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere
func (s *Server) sidFromPath(path string) (string, *errHTTP) { func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
parts := strings.Split(path, "/") parts := strings.Split(path, "/")
if len(parts) != 3 { if len(parts) != 3 {
return "", errHTTPBadRequestSIDInvalid return "", errHTTPBadRequestSIDInvalid

View File

@@ -684,7 +684,7 @@ func TestServer_PublishWithSIDInPath(t *testing.T) {
response := request(t, s, "POST", "/mytopic/sid", "message", nil) response := request(t, s, "POST", "/mytopic/sid", "message", nil)
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID) require.NotEmpty(t, msg.ID)
require.Equal(t, "sid", msg.SID) require.Equal(t, "sid", msg.SequenceID)
} }
func TestServer_PublishWithSIDInHeader(t *testing.T) { func TestServer_PublishWithSIDInHeader(t *testing.T) {
@@ -695,7 +695,7 @@ func TestServer_PublishWithSIDInHeader(t *testing.T) {
}) })
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID) require.NotEmpty(t, msg.ID)
require.Equal(t, "sid", msg.SID) require.Equal(t, "sid", msg.SequenceID)
} }
func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {
@@ -706,7 +706,7 @@ func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {
}) })
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID) 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) { 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) response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil)
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID) require.NotEmpty(t, msg.ID)
require.Equal(t, "sid1", msg.SID) require.Equal(t, "sid1", msg.SequenceID)
} }
func TestServer_PublishWithSIDViaGet(t *testing.T) { 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) response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil)
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID) require.NotEmpty(t, msg.ID)
require.Equal(t, "sid1", msg.SID) require.Equal(t, "sid1", msg.SequenceID)
} }
func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { func TestServer_PublishWithInvalidSIDInPath(t *testing.T) {

View File

@@ -24,11 +24,11 @@ const (
// message represents a message published to a topic // message represents a message published to a topic
type message struct { type message struct {
ID string `json:"id"` // Random message ID ID string `json:"id"` // Random message ID
SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as 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 Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above Event string `json:"event"` // One of the above
Topic string `json:"topic"` Topic string `json:"topic"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message"` // Allow empty message body Message string `json:"message"` // Allow empty message body
@@ -48,12 +48,12 @@ type message struct {
func (m *message) Context() log.Context { func (m *message) Context() log.Context {
fields := map[string]any{ fields := map[string]any{
"topic": m.Topic, "topic": m.Topic,
"message_id": m.ID, "message_id": m.ID,
"message_sid": m.SID, "message_sequence_id": m.SequenceID,
"message_time": m.Time, "message_time": m.Time,
"message_event": m.Event, "message_event": m.Event,
"message_body_size": len(m.Message), "message_body_size": len(m.Message),
} }
if m.Sender.IsValid() { if m.Sender.IsValid() {
fields["message_sender"] = m.Sender.String() fields["message_sender"] = m.Sender.String()
@@ -94,23 +94,23 @@ func newAction() *action {
// publishMessage is used as input when publishing as JSON // publishMessage is used as input when publishing as JSON
type publishMessage struct { type publishMessage struct {
Topic string `json:"topic"` Topic string `json:"topic"`
SID string `json:"sid"` SequenceID string `json:"sequence_id"`
Title string `json:"title"` Title string `json:"title"`
Message string `json:"message"` Message string `json:"message"`
Priority int `json:"priority"` Priority int `json:"priority"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Click string `json:"click"` Click string `json:"click"`
Icon string `json:"icon"` Icon string `json:"icon"`
Actions []action `json:"actions"` Actions []action `json:"actions"`
Attach string `json:"attach"` Attach string `json:"attach"`
Markdown bool `json:"markdown"` Markdown bool `json:"markdown"`
Filename string `json:"filename"` Filename string `json:"filename"`
Email string `json:"email"` Email string `json:"email"`
Call string `json:"call"` Call string `json:"call"`
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) 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) Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"` Delay string `json:"delay"`
} }
// messageEncoder is a function that knows how to encode a message // messageEncoder is a function that knows how to encode a message

View File

@@ -8,7 +8,7 @@ import { dbAsync } from "../src/app/db";
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n"; 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: * 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 // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too
// Delete existing notification with same SID (if any) // Delete existing notification with same sequence ID (if any)
if (message.sid) { const sequenceId = message.sequence_id || message.id;
await db.notifications.where({ subscriptionId, sid: message.sid }).delete(); if (sequenceId) {
await db.notifications.where({ subscriptionId, sequenceId }).delete();
} }
// Add notification to database // Add notification to database
await db.notifications.add({ await db.notifications.add({
...messageWithSID(message), ...messageWithSequenceId(message),
subscriptionId, 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
}); });

View File

@@ -47,22 +47,25 @@ class Poller {
// Filter out notifications older than the prune threshold // Filter out notifications older than the prune threshold
const deleteAfterSeconds = await prefs.deleteAfter(); const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0; 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 // 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 // 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) .filter(([, notification]) => notification.deleted)
.map(([sid]) => sid); .map(([sequenceId]) => sequenceId);
if (deletedSids.length > 0) { if (deletedSequenceIds.length > 0) {
console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSids); console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);
await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); await Promise.all(
deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))
);
} }
// Add only the latest notification for each non-deleted sequence // 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) { if (notificationsToAdd.length > 0) {
console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`);
await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); 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. * Groups notifications by sequenceId and returns only the latest (highest time) for each sequence.
* Returns an object mapping sid -> latest notification. * Returns an object mapping sequenceId -> latest notification.
*/ */
latestNotificationsBySid(notifications) { latestNotificationsBySequenceId(notifications) {
const latestBySid = {}; const latestBySequenceId = {};
notifications.forEach((notification) => { notifications.forEach((notification) => {
const sid = notification.sid || notification.id; const sequenceId = notification.sequence_id || notification.id;
if (!(sid in latestBySid) || notification.time >= latestBySid[sid].time) { if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) {
latestBySid[sid] = notification; latestBySequenceId[sequenceId] = notification;
} }
}); });
return latestBySid; return latestBySequenceId;
} }
} }

View File

@@ -2,7 +2,7 @@ import api from "./Api";
import notifier from "./Notifier"; import notifier from "./Notifier";
import prefs from "./Prefs"; import prefs from "./Prefs";
import db from "./db"; import db from "./db";
import { messageWithSID, topicUrl } from "./utils"; import { messageWithSequenceId, topicUrl } from "./utils";
class SubscriptionManager { class SubscriptionManager {
constructor(dbImpl) { constructor(dbImpl) {
@@ -15,7 +15,7 @@ class SubscriptionManager {
return Promise.all( return Promise.all(
subscriptions.map(async (s) => ({ subscriptions.map(async (s) => ({
...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, baseUrl,
topic, topic,
mutedUntil: 0, mutedUntil: 0,
last: null last: null,
}; };
await this.db.subscriptions.put(subscription); await this.db.subscriptions.put(subscription);
@@ -101,7 +101,7 @@ class SubscriptionManager {
const local = await this.add(remote.base_url, remote.topic, { const local = await this.add(remote.base_url, remote.topic, {
displayName: remote.display_name, // May be undefined displayName: remote.display_name, // May be undefined
reservation // May be null! reservation, // May be null!
}); });
return local.id; return local.id;
@@ -183,15 +183,15 @@ class SubscriptionManager {
// Add notification to database // Add notification to database
await this.db.notifications.add({ await this.db.notifications.add({
...messageWithSID(notification), ...messageWithSequenceId(notification),
subscriptionId, 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 // FIXME consider put() for double tab
// Update subscription last message id (for ?since=... queries) // Update subscription last message id (for ?since=... queries)
await this.db.subscriptions.update(subscriptionId, { await this.db.subscriptions.update(subscriptionId, {
last: notification.id last: notification.id,
}); });
} catch (e) { } catch (e) {
console.error(`[SubscriptionManager] Error adding notification`, e); console.error(`[SubscriptionManager] Error adding notification`, e);
@@ -202,12 +202,12 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */ /** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) { async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map((notification) => { const notificationsWithSubscriptionId = notifications.map((notification) => {
return { ...messageWithSID(notification), subscriptionId }; return { ...messageWithSequenceId(notification), subscriptionId };
}); });
const lastNotificationId = notifications.at(-1).id; const lastNotificationId = notifications.at(-1).id;
await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
await this.db.subscriptions.update(subscriptionId, { await this.db.subscriptions.update(subscriptionId, {
last: lastNotificationId last: lastNotificationId,
}); });
} }
@@ -228,8 +228,8 @@ class SubscriptionManager {
await this.db.notifications.delete(notificationId); await this.db.notifications.delete(notificationId);
} }
async deleteNotificationBySid(subscriptionId, sid) { async deleteNotificationBySequenceId(subscriptionId, sequenceId) {
await this.db.notifications.where({ subscriptionId, sid }).delete(); await this.db.notifications.where({ subscriptionId, sequenceId }).delete();
} }
async deleteNotifications(subscriptionId) { async deleteNotifications(subscriptionId) {
@@ -240,8 +240,8 @@ class SubscriptionManager {
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 }); await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
} }
async markNotificationReadBySid(subscriptionId, sid) { async markNotificationReadBySequenceId(subscriptionId, sequenceId) {
await this.db.notifications.where({ subscriptionId, sid }).modify({ new: 0 }); await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
} }
async markNotificationsRead(subscriptionId) { async markNotificationsRead(subscriptionId) {
@@ -250,19 +250,19 @@ class SubscriptionManager {
async setMutedUntil(subscriptionId, mutedUntil) { async setMutedUntil(subscriptionId, mutedUntil) {
await this.db.subscriptions.update(subscriptionId, { await this.db.subscriptions.update(subscriptionId, {
mutedUntil mutedUntil,
}); });
} }
async setDisplayName(subscriptionId, displayName) { async setDisplayName(subscriptionId, displayName) {
await this.db.subscriptions.update(subscriptionId, { await this.db.subscriptions.update(subscriptionId, {
displayName displayName,
}); });
} }
async setReservation(subscriptionId, reservation) { async setReservation(subscriptionId, reservation) {
await this.db.subscriptions.update(subscriptionId, { await this.db.subscriptions.update(subscriptionId, {
reservation reservation,
}); });
} }

View File

@@ -11,12 +11,11 @@ const createDatabase = (username) => {
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
const db = new Dexie(dbName); const db = new Dexie(dbName);
db.version(6).stores({ db.version(3).stores({
// FIXME Should be 3
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", 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", users: "&baseUrl,username",
prefs: "&key", prefs: "&key"
}); });
return db; return db;

View File

@@ -62,7 +62,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
icon, icon,
image, image,
timestamp: message.time * 1000, 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, renotify: true,
silent: false, silent: false,
// This is used by the notification onclick event // This is used by the notification onclick event

View File

@@ -103,9 +103,9 @@ export const maybeActionErrors = (notification) => {
return actionErrors; return actionErrors;
}; };
export const messageWithSID = (message) => { export const messageWithSequenceId = (message) => {
if (!message.sid) { if (!message.sequenceId) {
message.sid = message.id; message.sequenceId = message.sequence_id || message.id;
} }
return message; return message;
}; };

View File

@@ -53,9 +53,10 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
// Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived()
// and FirebaseService::handleMessage(). // and FirebaseService::handleMessage().
// Delete existing notification with same sid, if any // Delete existing notification with same sequenceId, if any
if (notification.sid) { const sequenceId = notification.sequence_id || notification.id;
await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); if (sequenceId) {
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId);
} }
// Add notification to database // Add notification to database
if (!notification.deleted) { if (!notification.deleted) {