From 83b5470bc57a24e25e0fa3daa65ab8573954c7a1 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 19:01:41 -0600 Subject: [PATCH] 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))