From be509766d8c83cada7fb26c5b3bab251d8e22c18 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Wed, 20 May 2026 17:10:45 +0200 Subject: [PATCH] feat: allow sending message with client/basic path --- api/message.go | 57 +++++++++++----- api/message_test.go | 113 ++++++++++++++++++++++++++++++++ auth/authentication.go | 5 ++ docs/spec.json | 14 +++- router/router.go | 2 +- router/router_test.go | 2 +- ui/src/message/MessagesStore.ts | 7 +- 7 files changed, 175 insertions(+), 25 deletions(-) diff --git a/api/message.go b/api/message.go index 3ebf6afd3..e6a2199e0 100644 --- a/api/message.go +++ b/api/message.go @@ -325,17 +325,20 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { }) } -// CreateMessage creates a message, authentication via application-token is required. +// CreateMessage creates a message, authentication via application token, client token, or basic auth is required. // swagger:operation POST /message message createMessage // // Create a message. // -// __NOTE__: This API ONLY accepts an application token as authentication. +// __NOTE__: When authenticating with a client token or basic auth, the request body +// must include "appid" referencing an application owned by the authenticated user. +// When authenticating with an application token, the application is derived from the +// token and any "appid" in the body is ignored. // // --- // consumes: [application/json] // produces: [application/json] -// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []] +// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: [], clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body @@ -362,26 +365,44 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { // $ref: "#/definitions/Error" func (a *MessageAPI) CreateMessage(ctx *gin.Context) { message := model.MessageExternal{} - if err := ctx.Bind(&message); err == nil { - application := auth.GetApplication(ctx) - message.ApplicationID = application.ID - if strings.TrimSpace(message.Title) == "" { - message.Title = application.Name - } + if err := ctx.Bind(&message); err != nil { + return + } - if message.Priority == nil { - message.Priority = &application.DefaultPriority + app := auth.GetApplication(ctx) + if app == nil { + if message.ApplicationID == 0 { + ctx.AbortWithError(400, errors.New("appid is required when not authenticating with an application token")) + return } - - message.Date = timeNow() - message.ID = 0 - msgInternal := toInternalMessage(&message) - if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success { + fetchedApp, err := a.DB.GetApplicationByID(message.ApplicationID) + if success := successOrAbort(ctx, 500, err); !success { + return + } + if fetchedApp == nil || fetchedApp.UserID != auth.GetUserID(ctx) { + ctx.AbortWithError(400, errors.New("appid not found")) return } - a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal)) - ctx.JSON(200, toExternalMessage(msgInternal)) + app = fetchedApp + } + + message.ApplicationID = app.ID + if strings.TrimSpace(message.Title) == "" { + message.Title = app.Name + } + + if message.Priority == nil { + message.Priority = &app.DefaultPriority + } + + message.Date = timeNow() + message.ID = 0 + msgInternal := toInternalMessage(&message) + if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success { + return } + a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal)) + ctx.JSON(200, toExternalMessage(msgInternal)) } func toInternalMessage(msg *model.MessageExternal) *model.Message { diff --git a/api/message_test.go b/api/message_test.go index ce9eb6ff0..d2561b81f 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -536,6 +536,119 @@ func (s *MessageSuite) Test_CreateMessage_onFormData() { assert.Equal(s.T(), uint(1), s.notifiedMessage.ID) } +func (s *MessageSuite) Test_CreateMessage_clientToken_usesBodyAppId() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + user := s.db.User(4) + user.NewAppWithToken(7, "app-token") + auth.RegisterClient(s.ctx, user.NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(7) + assert.NoError(s.T(), err) + expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} + assert.Len(s.T(), msgs, 1) + assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_clientToken_missingAppId_400() { + user := s.db.User(4) + user.NewAppWithToken(7, "app-token") + auth.RegisterClient(s.ctx, user.NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Nil(s.T(), s.notifiedMessage) + if msgs, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) { + assert.Empty(s.T(), msgs) + } +} + +func (s *MessageSuite) Test_CreateMessage_clientToken_appNotOwned_400() { + s.db.User(5).NewAppWithToken(7, "other-app-token") + auth.RegisterClient(s.ctx, s.db.User(4).NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Nil(s.T(), s.notifiedMessage) + if msgs, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) { + assert.Empty(s.T(), msgs) + } +} + +func (s *MessageSuite) Test_CreateMessage_clientToken_unknownAppId_400() { + auth.RegisterClient(s.ctx, s.db.User(4).NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 999, "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Nil(s.T(), s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_basicAuth_usesBodyAppId() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + s.db.User(4).NewAppWithToken(7, "app-token") + test.WithUser(s.ctx, 4) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(7) + assert.NoError(s.T(), err) + expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} + assert.Len(s.T(), msgs, 1) + assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_appToken_ignoresBodyAppId() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + user := s.db.User(4) + user.NewAppWithToken(7, "other-app-token") + auth.RegisterApplication(s.ctx, user.NewAppWithToken(8, "app-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(8) + assert.NoError(s.T(), err) + assert.Len(s.T(), msgs, 1) + if msgs7, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) { + assert.Empty(s.T(), msgs7) + } + assert.Equal(s.T(), 200, s.recorder.Code) +} + func (s *MessageSuite) withURL(scheme, host, path, query string) { s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query} s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) diff --git a/auth/authentication.go b/auth/authentication.go index 4b4310a8f..2d52e2146 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -75,6 +75,11 @@ func (a *Auth) RequireApplicationToken(ctx *gin.Context) { a.abort401(ctx) } +// RequireAny requires client, application, or basic auth. +func (a *Auth) RequireApplicationOrClient(ctx *gin.Context) { + a.evaluateOr401(ctx, a.handleApplication, a.handleClient(), a.handleUser()) +} + func (a *Auth) Optional(ctx *gin.Context) { if !a.evaluate(ctx, a.handleUser(), a.handleClient()) { ctx.Next() diff --git a/docs/spec.json b/docs/spec.json index 96780c2d3..04be96460 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -1438,9 +1438,21 @@ }, { "appTokenQuery": [] + }, + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] } ], - "description": "__NOTE__: This API ONLY accepts an application token as authentication.", + "description": "__NOTE__: When authenticating with a client token or basic auth, the request body\nmust include \"appid\" referencing an application owned by the authenticated user.\nWhen authenticating with an application token, the application is derived from the\ntoken and any \"appid\" in the body is ignored.", "consumes": [ "application/json" ], diff --git a/router/router.go b/router/router.go index a57ca1fe1..c7c1f24c2 100644 --- a/router/router.go +++ b/router/router.go @@ -188,7 +188,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co ctx.JSON(200, &model.GotifyInfo{Version: vInfo.Version, Oidc: conf.OIDC.Enabled, Register: conf.Registration}) }) - g.Group("/").Use(authentication.RequireApplicationToken).POST("/message", messageHandler.CreateMessage) + g.Group("/").Use(authentication.RequireApplicationOrClient).POST("/message", messageHandler.CreateMessage) clientAuth := g.Group("") { diff --git a/router/router_test.go b/router/router_test.go index cb2a5833b..4ac7c1232 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -385,7 +385,7 @@ func (s *IntegrationSuite) TestAuthentication() { req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup"}`) req.SetBasicAuth("normal", "secret") - doRequestAndExpect(s.T(), req, 403, forbiddenJSON) + doRequestAndExpect(s.T(), req, 400, `{"error":"Bad Request", "errorCode":400, "errorDescription":"appid is required when not authenticating with an application token"}`) req = s.newRequest("GET", "current/user", "") req.SetBasicAuth("normal", "secret") diff --git a/ui/src/message/MessagesStore.ts b/ui/src/message/MessagesStore.ts index e0be40708..e72ac558d 100644 --- a/ui/src/message/MessagesStore.ts +++ b/ui/src/message/MessagesStore.ts @@ -141,15 +141,14 @@ export class MessagesStore { priority: number ): Promise => { const app = this.appStore.getByID(appId); - const payload: Pick = { + const payload: Pick = { + appid: appId, message, priority, title, }; - await axios.post(`${config.get('url')}message`, payload, { - headers: {'X-Gotify-Key': app.token}, - }); + await axios.post(`${config.get('url')}message`, payload); this.snack(`Message sent to ${app.name}`); };