diff --git a/docs/publish.md b/docs/publish.md index 46f2d80a..f8328f84 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2815,16 +2815,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" @@ -4143,26 +4147,41 @@ field the response. A sequence of updates may look like this (first example from ### Clearing notifications 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). +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. +Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints: +`GET ///clear` or `GET ///read`. + === "Command line (curl)" ```bash + # Via PUT method curl -X PUT ntfy.sh/mytopic/my-download-123/clear + + # Via GET method + curl ntfy.sh/mytopic/my-download-123/clear ``` === "HTTP" ``` http PUT /mytopic/my-download-123/clear HTTP/1.1 Host: ntfy.sh + + # Or using GET + GET /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh ``` === "JavaScript" ``` javascript + // Via PUT method await fetch('https://ntfy.sh/mytopic/my-download-123/clear', { method: 'PUT' }); + + // Via GET method + await fetch('https://ntfy.sh/mytopic/my-download-123/clear'); ``` === "Go" @@ -4197,25 +4216,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/docs/releases.md b/docs/releases.md index e43b1d08..51d80d53 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1962,6 +1962,7 @@ since I do have to reset emails on a regular basis. * Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins * Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI +* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) **Bug fixes + maintenance:** diff --git a/server/server.go b/server/server.go index fb5d5d37..b35eb3a9 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,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 webAppConfigPath = "/config.js" @@ -654,9 +655,9 @@ 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) { + } else if (r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path)) || (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) { + } else if (r.Method == http.MethodGet || 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) diff --git a/server/server_test.go b/server/server_test.go index 5ebde045..24355fbc 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4139,6 +4139,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() @@ -4192,6 +4226,33 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { }) } +func TestServer_ClearMessage_GET(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // 1. Test GET /topic/seq-id/clear + response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil) + require.Equal(t, 200, response.Code) + clearMsg1 := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", clearMsg1.SequenceID) + require.Equal(t, "message_clear", clearMsg1.Event) + + // 2. Test GET /topic/seq-id/read + response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/mytopic/seq789/read", "", nil) + require.Equal(t, 200, response.Code) + clearMsg2 := toMessage(t, response.Body.String()) + require.Equal(t, "seq789", clearMsg2.SequenceID) + require.Equal(t, "message_clear", clearMsg2.Event) + }) +} + func TestServer_UpdateMessage(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() @@ -4399,6 +4460,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()