diff --git a/server/server.go b/server/server.go index eb0fd120..819b43a1 100644 --- a/server/server.go +++ b/server/server.go @@ -77,6 +77,7 @@ 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)$`) + messagePathRegex = regexp.MustCompile(`^/[-A-Za-z-0-9]{1,64}/([A-Za-z0-9]{12})$`) webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -537,6 +538,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { return s.ensureWebEnabled(s.handleTopic)(w, r, v) + } else if r.Method == http.MethodDelete && (messagePathRegex.MatchString(r.URL.Path)) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMessageUnpublish))(w, r, v) } return errHTTPNotFound } @@ -558,6 +561,25 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) return s.handleStatic(w, r, v) } +func (s *Server) handleMessageUnpublish(w http.ResponseWriter, r *http.Request, v *visitor) error { + segments := strings.Split(r.URL.Path, "/") + msg, err := s.messageCache.Message(segments[len(segments)-1]) + if err != nil { + if err == errMessageNotFound { + return errHTTPNotFound + } + return err + } + if time.Now().Unix() >= msg.Time { + httpErr := errHTTPBadRequest + alreadyPublished := httpErr.Wrap("Message \"%s\" has already been published", msg.ID) + s.handleError(w, r, v, alreadyPublished) + return alreadyPublished + } + s.messageCache.DeleteMessages(msg.ID) + return s.writeJSON(w, msg) +} + func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error { return nil } diff --git a/server/server_test.go b/server/server_test.go index ef9157cb..9a1ef37f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -7,8 +7,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/user" "io" "net/http" "net/http/httptest" @@ -22,6 +20,9 @@ import ( "testing" "time" + "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/user" + "github.com/SherClockHolmes/webpush-go" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/log" @@ -2853,6 +2854,64 @@ template ""}}`, } } +func TestServer_Message_Unpublish_Existing(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response1 := request(t, s, "PUT", "/mytopic", "hello universe", map[string]string{ + "In": "1m", + }) + msg1 := toMessage(t, response1.Body.String()) + time.Sleep(500) + + response2 := request(t, s, "DELETE", fmt.Sprintf("/mytopic/%s", msg1.ID), "", nil) + require.Equal(t, 200, response2.Code) + msg2 := toMessage(t, response2.Body.String()) + require.Equal(t, msg1.ID, msg2.ID) +} + +func TestServer_Message_Unpublish_Nonexistent(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + response2 := request(t, s, "DELETE", "/mytopic/n0nexist3nt1", "", nil) + require.Equal(t, 404, response2.Code) +} + +func TestServer_Message_Unpublish_Protected_Fail_Unauthorized(t *testing.T) { + c := newTestConfigWithAuthFile(t) + s := newTestServer(t, c) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) + + response1 := request(t, s, "PUT", "/announcements", "hello universe", map[string]string{ + "In": "1m", + "Authorization": util.BasicAuth("phil", "phil"), + }) + msg1 := toMessage(t, response1.Body.String()) + + response2 := request(t, s, "DELETE", fmt.Sprintf("/announcements/%s", msg1.ID), "", nil) + require.Equal(t, 403, response2.Code) +} +func TestServer_Message_Unpublish_Private(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) + require.Nil(t, s.userManager.AllowAccess("phil", "announcements", user.PermissionReadWrite)) + + h := map[string]string{ + "In": "1m", + "Authorization": util.BasicAuth("phil", "phil"), + } + response1 := request(t, s, "PUT", "/announcements", "hello universe", h) + msg1 := toMessage(t, response1.Body.String()) + + response2 := request(t, s, "DELETE", fmt.Sprintf("/announcements/%s", msg1.ID), "", h) + msg2 := toMessage(t, response2.Body.String()) + require.Equal(t, 200, response2.Code) + require.Equal(t, msg1.ID, msg2.ID) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345"