diff --git a/docs/publish.md b/docs/publish.md index 9ac600e9..5149a232 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2777,16 +2777,20 @@ Here's an example of a dead man's switch that sends an alert if the script stops ### Canceling scheduled notifications You can cancel a scheduled message before it is delivered by sending a DELETE request to the -`//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the -scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. +`//` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET` +request to `///delete`. This will remove the scheduled message from the server so it will never be delivered, +and emit a `message_delete` event to any subscribers. === "Command line (curl)" ```bash # Schedule a reminder for 2 hours from now curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder - # Changed your mind? Cancel the scheduled message + # Changed your mind? Cancel the scheduled message via DELETE curl -X DELETE ntfy.sh/mytopic/break-reminder + + # Or cancel it via GET + curl ntfy.sh/mytopic/break-reminder/delete ``` === "ntfy CLI" @@ -4154,25 +4158,40 @@ An example response from the server with the `message_clear` event may look like ### Deleting notifications 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 +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. +Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending +a `GET` request to `///delete`. + === "Command line (curl)" ```bash + # Via DELETE method curl -X DELETE ntfy.sh/mytopic/my-download-123 + + # Via GET method + curl ntfy.sh/mytopic/my-download-123/delete ``` === "HTTP" ``` http DELETE /mytopic/my-download-123 HTTP/1.1 Host: ntfy.sh + + # Or using GET + GET /mytopic/my-download-123/delete HTTP/1.1 + Host: ntfy.sh ``` === "JavaScript" ``` javascript + // Via DELETE method await fetch('https://ntfy.sh/mytopic/my-download-123', { method: 'DELETE' }); + + // Via GET method + await fetch('https://ntfy.sh/mytopic/my-download-123/delete'); ``` === "Go" diff --git a/server/server.go b/server/server.go index c380bd26..d4586728 100644 --- a/server/server.go +++ b/server/server.go @@ -91,6 +91,7 @@ var ( 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}$`) clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) + deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`) sequenceIDRegex = topicRegex webConfigPath = "/config.js" @@ -645,6 +646,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.MethodGet && deletePathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(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) { diff --git a/server/server_test.go b/server/server_test.go index bb4ddfba..e1d4afe8 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4026,6 +4026,40 @@ func TestServer_DeleteMessage(t *testing.T) { }) } +func TestServer_DeleteMessage_GET(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // 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 GET method (/topic/seq/delete) + response = request(t, s, "GET", "/mytopic/seq123/delete", "", 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) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() @@ -4286,6 +4320,41 @@ func TestServer_DeleteScheduledMessage(t *testing.T) { }) } +func TestServer_DeleteScheduledMessage_GET(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // Publish a scheduled message (future delivery) + response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", msg.SequenceID) + + // Verify scheduled message exists + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "scheduled message to delete", messages[0].Message) + + // Delete the scheduled message using GET method (/topic/seq/delete) + response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Verify scheduled message was deleted, only delete event remains + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message_delete", messages[0].Event) + require.Equal(t, "delete-sched-seq", messages[0].SequenceID) + }) +} + func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel()