binwiederhier 3 سال پیش
والد
کامیت
57814cf855
8فایلهای تغییر یافته به همراه209 افزوده شده و 37 حذف شده
  1. 6 4
      server/errors.go
  2. 8 11
      server/server.go
  3. 11 15
      server/server_account.go
  4. 157 0
      server/server_account_test.go
  5. 1 1
      server/server_matrix.go
  6. 1 1
      server/server_matrix_test.go
  7. 13 0
      server/util.go
  8. 12 5
      util/util.go

+ 6 - 4
server/errors.go

@@ -48,19 +48,21 @@ var (
 	errHTTPBadRequestAttachmentsDisallowed           = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
 	errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
 	errHTTPBadRequestWebSocketsUpgradeHeaderMissing  = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
-	errHTTPBadRequestJSONInvalid                     = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
+	errHTTPBadRequestMessageJSONInvalid              = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
 	errHTTPBadRequestActionsInvalid                  = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
 	errHTTPBadRequestMatrixMessageInvalid            = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
 	errHTTPBadRequestMatrixPushkeyBaseURLMismatch    = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
 	errHTTPBadRequestIconURLInvalid                  = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
 	errHTTPBadRequestSignupNotEnabled                = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
 	errHTTPBadRequestNoTokenProvided                 = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
+	errHTTPBadRequestJSONInvalid                     = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
 	errHTTPConflictUserExists                        = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
-	errHTTPEntityTooLargeAttachmentTooLarge          = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
-	errHTTPEntityTooLargeMatrixRequestTooLarge       = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
+	errHTTPEntityTooLargeAttachment                  = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
+	errHTTPEntityTooLargeMatrixRequest               = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
+	errHTTPEntityTooLargeJSONBody                    = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
 	errHTTPTooManyRequestsLimitRequests              = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
 	errHTTPTooManyRequestsLimitEmails                = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
 	errHTTPTooManyRequestsLimitSubscriptions         = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
@@ -68,6 +70,6 @@ var (
 	errHTTPTooManyRequestsAttachmentBandwidthLimit   = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
 	errHTTPTooManyRequestsAccountCreateLimit         = &errHTTP{42906, http.StatusTooManyRequests, "too many requests: daily account creation limit reached", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
 	errHTTPInternalError                             = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
-	errHTTPInternalErrorInvalidFilePath              = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
+	errHTTPInternalErrorInvalidPath                  = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
 	errHTTPInternalErrorMissingBaseURL               = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
 )

+ 8 - 11
server/server.go

@@ -479,7 +479,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
 	}
 	matches := fileRegex.FindStringSubmatch(r.URL.Path)
 	if len(matches) != 2 {
-		return errHTTPInternalErrorInvalidFilePath
+		return errHTTPInternalErrorInvalidPath
 	}
 	messageID := matches[1]
 	file := filepath.Join(s.config.AttachmentCacheDir, messageID)
@@ -815,7 +815,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
 	if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
 		contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
 		if err == nil && (contentLength > stats.AttachmentTotalSizeRemaining || contentLength > stats.AttachmentFileSizeLimit) {
-			return errHTTPEntityTooLargeAttachmentTooLarge
+			return errHTTPEntityTooLargeAttachment
 		}
 	}
 	if m.Attachment == nil {
@@ -839,7 +839,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
 	}
 	m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
 	if err == util.ErrLimitReached {
-		return errHTTPEntityTooLargeAttachmentTooLarge
+		return errHTTPEntityTooLargeAttachment
 	} else if err != nil {
 		return err
 	}
@@ -1426,15 +1426,10 @@ func (s *Server) ensureUser(next handleFunc) handleFunc {
 // before passing it on to the next handler. This is meant to be used in combination with handlePublish.
 func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
 	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
-		body, err := util.Peek(r.Body, s.config.MessageLimit)
+		m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit)
 		if err != nil {
 			return err
 		}
-		defer r.Body.Close()
-		var m publishMessage
-		if err := json.NewDecoder(body).Decode(&m); err != nil {
-			return errHTTPBadRequestJSONInvalid
-		}
 		if !topicRegex.MatchString(m.Topic) {
 			return errHTTPBadRequestTopicInvalid
 		}
@@ -1467,7 +1462,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
 		if len(m.Actions) > 0 {
 			actionsStr, err := json.Marshal(m.Actions)
 			if err != nil {
-				return errHTTPBadRequestJSONInvalid
+				return errHTTPBadRequestMessageJSONInvalid
 			}
 			r.Header.Set("X-Actions", string(actionsStr))
 		}
@@ -1535,7 +1530,9 @@ func (s *Server) visitor(r *http.Request) (v *visitor, err error) {
 	} else {
 		v = s.visitorFromIP(ip)
 	}
-	v.user = u    // Update user -- FIXME race?
+	v.mu.Lock()
+	v.user = u
+	v.mu.Unlock()
 	return v, err // Always return visitor, even when error occurs!
 }
 

+ 11 - 15
server/server_account.go

@@ -2,7 +2,6 @@ package server
 
 import (
 	"encoding/json"
-	"errors"
 	"heckel.io/ntfy/user"
 	"heckel.io/ntfy/util"
 	"net/http"
@@ -21,7 +20,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
 			return errHTTPUnauthorized // Cannot create account from user context
 		}
 	}
-	newAccount, err := util.ReadJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
+	newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
 	if err != nil {
 		return err
 	}
@@ -118,7 +117,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
 }
 
 func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	newPassword, err := util.ReadJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
+	newPassword, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
 	if err != nil {
 		return err
 	}
@@ -174,7 +173,7 @@ func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, r *http.Request
 func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	// TODO rate limit
 	if v.user.Token == "" {
-		return errHTTPUnauthorized
+		return errHTTPBadRequestNoTokenProvided
 	}
 	if err := s.userManager.RemoveToken(v.user); err != nil {
 		return err
@@ -184,7 +183,7 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
 }
 
 func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	newPrefs, err := util.ReadJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit)
+	newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit)
 	if err != nil {
 		return err
 	}
@@ -218,7 +217,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
 }
 
 func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	newSubscription, err := util.ReadJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
+	newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
 	if err != nil {
 		return err
 	}
@@ -250,13 +249,13 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
 func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
 	if len(matches) != 2 {
-		return errHTTPInternalErrorInvalidFilePath // FIXME
+		return errHTTPInternalErrorInvalidPath
 	}
-	updatedSubscription, err := util.ReadJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
+	subscriptionID := matches[1]
+	updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
 	if err != nil {
 		return err
 	}
-	subscriptionID := matches[1]
 	if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
 		return errHTTPNotFound
 	}
@@ -283,14 +282,9 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
 }
 
 func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	if v.user == nil {
-		return errors.New("no user")
-	}
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 	matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
 	if len(matches) != 2 {
-		return errHTTPInternalErrorInvalidFilePath // FIXME
+		return errHTTPInternalErrorInvalidPath
 	}
 	subscriptionID := matches[1]
 	if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
@@ -308,5 +302,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
 			return err
 		}
 	}
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 	return nil
 }

+ 157 - 0
server/server_account_test.go

@@ -62,6 +62,25 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
 	require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)
 }
 
+func TestAccount_Signup_AsUser(t *testing.T) {
+	conf := newTestConfigWithUsers(t)
+	conf.EnableSignup = true
+	s := newTestServer(t, conf)
+
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
+	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+
+	rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
+		"Authorization": util.BasicAuth("ben", "ben"),
+	})
+	require.Equal(t, 401, rr.Code)
+}
+
 func TestAccount_Signup_Disabled(t *testing.T) {
 	conf := newTestConfigWithUsers(t)
 	conf.EnableSignup = false
@@ -112,6 +131,144 @@ func TestAccount_Get_Anonymous(t *testing.T) {
 	require.Equal(t, int64(23), account.Stats.EmailsRemaining)
 }
 
+func TestAccount_ChangeSettings(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithUsers(t))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+	user, _ := s.userManager.User("phil")
+	token, _ := s.userManager.CreateToken(user)
+
+	rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"delete_after": 86400}, "language": "de"}`, map[string]string{
+		"Authorization": util.BearerAuth(token.Value),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
+		"Authorization": util.BearerAuth(token.Value),
+	})
+	require.Equal(t, 200, rr.Code)
+	account, _ := util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body))
+	require.Equal(t, "de", account.Language)
+	require.Equal(t, 86400, account.Notification.DeleteAfter)
+	require.Equal(t, "juntos", account.Notification.Sound)
+	require.Equal(t, 0, account.Notification.MinPriority) // Not set
+}
+
+func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithUsers(t))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+
+	rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "GET", "/v1/account", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+	account, _ := util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body))
+	require.Equal(t, 1, len(account.Subscriptions))
+	require.NotEmpty(t, account.Subscriptions[0].ID)
+	require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
+	require.Equal(t, "def", account.Subscriptions[0].Topic)
+	require.Equal(t, "", account.Subscriptions[0].DisplayName)
+
+	subscriptionID := account.Subscriptions[0].ID
+	rr = request(t, s, "PATCH", "/v1/account/subscription/"+subscriptionID, `{"display_name": "ding dong"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "GET", "/v1/account", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+	account, _ = util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body))
+	require.Equal(t, 1, len(account.Subscriptions))
+	require.Equal(t, subscriptionID, account.Subscriptions[0].ID)
+	require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
+	require.Equal(t, "def", account.Subscriptions[0].Topic)
+	require.Equal(t, "ding dong", account.Subscriptions[0].DisplayName)
+
+	rr = request(t, s, "DELETE", "/v1/account/subscription/"+subscriptionID, "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "GET", "/v1/account", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+	account, _ = util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body))
+	require.Equal(t, 0, len(account.Subscriptions))
+}
+
+func TestAccount_ChangePassword(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithUsers(t))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+
+	rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s, "GET", "/v1/account", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 401, rr.Code)
+
+	rr = request(t, s, "GET", "/v1/account", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "new password"),
+	})
+	require.Equal(t, 200, rr.Code)
+}
+
+func TestAccount_ChangePassword_NoAccount(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithUsers(t))
+
+	rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil)
+	require.Equal(t, 401, rr.Code)
+}
+
+func TestAccount_ExtendToken(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithUsers(t))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+
+	rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+	token, err := util.ReadJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
+	require.Nil(t, err)
+
+	time.Sleep(time.Second)
+
+	rr = request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
+		"Authorization": util.BearerAuth(token.Token),
+	})
+	require.Equal(t, 200, rr.Code)
+	extendedToken, err := util.ReadJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
+	require.Nil(t, err)
+	require.Equal(t, token.Token, extendedToken.Token)
+	require.True(t, token.Expires < extendedToken.Expires)
+}
+
+func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithUsers(t))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+
+	rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
+	})
+	require.Equal(t, 400, rr.Code)
+	require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code)
+}
+
 func TestAccount_Delete_Success(t *testing.T) {
 	conf := newTestConfigWithUsers(t)
 	conf.EnableSignup = true

+ 1 - 1
server/server_matrix.go

@@ -113,7 +113,7 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
 	}
 	defer r.Body.Close()
 	if body.LimitReached {
-		return nil, errHTTPEntityTooLargeMatrixRequestTooLarge
+		return nil, errHTTPEntityTooLargeMatrixRequest
 	}
 	var m matrixRequest
 	if err := json.Unmarshal(body.PeekedBytes, &m); err != nil {

+ 1 - 1
server/server_matrix_test.go

@@ -29,7 +29,7 @@ func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) {
 	body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
 	r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
 	_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
-	require.Equal(t, errHTTPEntityTooLargeMatrixRequestTooLarge, err)
+	require.Equal(t, errHTTPEntityTooLargeMatrixRequest, err)
 }
 
 func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {

+ 13 - 0
server/util.go

@@ -5,6 +5,7 @@ import (
 	"github.com/emersion/go-smtp"
 	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/util"
+	"io"
 	"net/http"
 	"net/netip"
 	"strings"
@@ -121,3 +122,15 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
 	}
 	return ip
 }
+
+func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
+	obj, err := util.ReadJSONWithLimit[T](r, limit)
+	if err == util.ErrInvalidJSON {
+		return nil, errHTTPBadRequestJSONInvalid
+	} else if err == util.ErrTooLargeJSON {
+		return nil, errHTTPEntityTooLargeJSONBody
+	} else if err != nil {
+		return nil, err
+	}
+	return obj, nil
+}

+ 12 - 5
util/util.go

@@ -31,6 +31,11 @@ var (
 	noQuotesRegex      = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
 )
 
+var (
+	ErrInvalidJSON  = errors.New("invalid JSON")
+	ErrTooLargeJSON = errors.New("too large JSON")
+)
+
 // FileExists checks if a file exists, and returns true if it does
 func FileExists(filename string) bool {
 	stat, _ := os.Stat(filename)
@@ -293,21 +298,23 @@ func QuoteCommand(command []string) string {
 func ReadJSON[T any](body io.ReadCloser) (*T, error) {
 	var obj T
 	if err := json.NewDecoder(body).Decode(&obj); err != nil {
-		return nil, err
+		return nil, ErrInvalidJSON
 	}
 	return &obj, nil
 }
 
 // ReadJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached
 func ReadJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
-	r, err := Peek(r, limit)
+	defer r.Close()
+	p, err := Peek(r, limit)
 	if err != nil {
 		return nil, err
+	} else if p.LimitReached {
+		return nil, ErrTooLargeJSON
 	}
-	defer r.Close()
 	var obj T
-	if err := json.NewDecoder(r).Decode(&obj); err != nil {
-		return nil, err
+	if err := json.NewDecoder(p).Decode(&obj); err != nil {
+		return nil, ErrInvalidJSON
 	}
 	return &obj, nil
 }