Skip to content

Commit 0e32e56

Browse files
authored
Merge pull request #964 from gotify/appid
feat: allow sending message with client/basic path
2 parents a309216 + be50976 commit 0e32e56

7 files changed

Lines changed: 175 additions & 25 deletions

File tree

api/message.go

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -325,17 +325,20 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) {
325325
})
326326
}
327327

328-
// CreateMessage creates a message, authentication via application-token is required.
328+
// CreateMessage creates a message, authentication via application token, client token, or basic auth is required.
329329
// swagger:operation POST /message message createMessage
330330
//
331331
// Create a message.
332332
//
333-
// __NOTE__: This API ONLY accepts an application token as authentication.
333+
// __NOTE__: When authenticating with a client token or basic auth, the request body
334+
// must include "appid" referencing an application owned by the authenticated user.
335+
// When authenticating with an application token, the application is derived from the
336+
// token and any "appid" in the body is ignored.
334337
//
335338
// ---
336339
// consumes: [application/json]
337340
// produces: [application/json]
338-
// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []]
341+
// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: [], clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
339342
// parameters:
340343
// - name: body
341344
// in: body
@@ -362,26 +365,44 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) {
362365
// $ref: "#/definitions/Error"
363366
func (a *MessageAPI) CreateMessage(ctx *gin.Context) {
364367
message := model.MessageExternal{}
365-
if err := ctx.Bind(&message); err == nil {
366-
application := auth.GetApplication(ctx)
367-
message.ApplicationID = application.ID
368-
if strings.TrimSpace(message.Title) == "" {
369-
message.Title = application.Name
370-
}
368+
if err := ctx.Bind(&message); err != nil {
369+
return
370+
}
371371

372-
if message.Priority == nil {
373-
message.Priority = &application.DefaultPriority
372+
app := auth.GetApplication(ctx)
373+
if app == nil {
374+
if message.ApplicationID == 0 {
375+
ctx.AbortWithError(400, errors.New("appid is required when not authenticating with an application token"))
376+
return
374377
}
375-
376-
message.Date = timeNow()
377-
message.ID = 0
378-
msgInternal := toInternalMessage(&message)
379-
if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success {
378+
fetchedApp, err := a.DB.GetApplicationByID(message.ApplicationID)
379+
if success := successOrAbort(ctx, 500, err); !success {
380+
return
381+
}
382+
if fetchedApp == nil || fetchedApp.UserID != auth.GetUserID(ctx) {
383+
ctx.AbortWithError(400, errors.New("appid not found"))
380384
return
381385
}
382-
a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal))
383-
ctx.JSON(200, toExternalMessage(msgInternal))
386+
app = fetchedApp
387+
}
388+
389+
message.ApplicationID = app.ID
390+
if strings.TrimSpace(message.Title) == "" {
391+
message.Title = app.Name
392+
}
393+
394+
if message.Priority == nil {
395+
message.Priority = &app.DefaultPriority
396+
}
397+
398+
message.Date = timeNow()
399+
message.ID = 0
400+
msgInternal := toInternalMessage(&message)
401+
if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success {
402+
return
384403
}
404+
a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal))
405+
ctx.JSON(200, toExternalMessage(msgInternal))
385406
}
386407

387408
func toInternalMessage(msg *model.MessageExternal) *model.Message {

api/message_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,119 @@ func (s *MessageSuite) Test_CreateMessage_onFormData() {
536536
assert.Equal(s.T(), uint(1), s.notifiedMessage.ID)
537537
}
538538

539+
func (s *MessageSuite) Test_CreateMessage_clientToken_usesBodyAppId() {
540+
t, _ := time.Parse("2006/01/02", "2017/01/02")
541+
timeNow = func() time.Time { return t }
542+
defer func() { timeNow = time.Now }()
543+
544+
user := s.db.User(4)
545+
user.NewAppWithToken(7, "app-token")
546+
auth.RegisterClient(s.ctx, user.NewClientWithToken(1, "client-token"))
547+
548+
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`))
549+
s.ctx.Request.Header.Set("Content-Type", "application/json")
550+
551+
s.a.CreateMessage(s.ctx)
552+
553+
msgs, err := s.db.GetMessagesByApplication(7)
554+
assert.NoError(s.T(), err)
555+
expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
556+
assert.Len(s.T(), msgs, 1)
557+
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
558+
assert.Equal(s.T(), 200, s.recorder.Code)
559+
assert.Equal(s.T(), expected, s.notifiedMessage)
560+
}
561+
562+
func (s *MessageSuite) Test_CreateMessage_clientToken_missingAppId_400() {
563+
user := s.db.User(4)
564+
user.NewAppWithToken(7, "app-token")
565+
auth.RegisterClient(s.ctx, user.NewClientWithToken(1, "client-token"))
566+
567+
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`))
568+
s.ctx.Request.Header.Set("Content-Type", "application/json")
569+
570+
s.a.CreateMessage(s.ctx)
571+
572+
assert.Equal(s.T(), 400, s.recorder.Code)
573+
assert.Nil(s.T(), s.notifiedMessage)
574+
if msgs, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) {
575+
assert.Empty(s.T(), msgs)
576+
}
577+
}
578+
579+
func (s *MessageSuite) Test_CreateMessage_clientToken_appNotOwned_400() {
580+
s.db.User(5).NewAppWithToken(7, "other-app-token")
581+
auth.RegisterClient(s.ctx, s.db.User(4).NewClientWithToken(1, "client-token"))
582+
583+
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "message": "mymessage"}`))
584+
s.ctx.Request.Header.Set("Content-Type", "application/json")
585+
586+
s.a.CreateMessage(s.ctx)
587+
588+
assert.Equal(s.T(), 400, s.recorder.Code)
589+
assert.Nil(s.T(), s.notifiedMessage)
590+
if msgs, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) {
591+
assert.Empty(s.T(), msgs)
592+
}
593+
}
594+
595+
func (s *MessageSuite) Test_CreateMessage_clientToken_unknownAppId_400() {
596+
auth.RegisterClient(s.ctx, s.db.User(4).NewClientWithToken(1, "client-token"))
597+
598+
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 999, "message": "mymessage"}`))
599+
s.ctx.Request.Header.Set("Content-Type", "application/json")
600+
601+
s.a.CreateMessage(s.ctx)
602+
603+
assert.Equal(s.T(), 400, s.recorder.Code)
604+
assert.Nil(s.T(), s.notifiedMessage)
605+
}
606+
607+
func (s *MessageSuite) Test_CreateMessage_basicAuth_usesBodyAppId() {
608+
t, _ := time.Parse("2006/01/02", "2017/01/02")
609+
timeNow = func() time.Time { return t }
610+
defer func() { timeNow = time.Now }()
611+
612+
s.db.User(4).NewAppWithToken(7, "app-token")
613+
test.WithUser(s.ctx, 4)
614+
615+
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`))
616+
s.ctx.Request.Header.Set("Content-Type", "application/json")
617+
618+
s.a.CreateMessage(s.ctx)
619+
620+
msgs, err := s.db.GetMessagesByApplication(7)
621+
assert.NoError(s.T(), err)
622+
expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t}
623+
assert.Len(s.T(), msgs, 1)
624+
assert.Equal(s.T(), expected, toExternalMessage(msgs[0]))
625+
assert.Equal(s.T(), 200, s.recorder.Code)
626+
assert.Equal(s.T(), expected, s.notifiedMessage)
627+
}
628+
629+
func (s *MessageSuite) Test_CreateMessage_appToken_ignoresBodyAppId() {
630+
t, _ := time.Parse("2006/01/02", "2017/01/02")
631+
timeNow = func() time.Time { return t }
632+
defer func() { timeNow = time.Now }()
633+
634+
user := s.db.User(4)
635+
user.NewAppWithToken(7, "other-app-token")
636+
auth.RegisterApplication(s.ctx, user.NewAppWithToken(8, "app-token"))
637+
638+
s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`))
639+
s.ctx.Request.Header.Set("Content-Type", "application/json")
640+
641+
s.a.CreateMessage(s.ctx)
642+
643+
msgs, err := s.db.GetMessagesByApplication(8)
644+
assert.NoError(s.T(), err)
645+
assert.Len(s.T(), msgs, 1)
646+
if msgs7, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) {
647+
assert.Empty(s.T(), msgs7)
648+
}
649+
assert.Equal(s.T(), 200, s.recorder.Code)
650+
}
651+
539652
func (s *MessageSuite) withURL(scheme, host, path, query string) {
540653
s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query}
541654
s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host})

auth/authentication.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ func (a *Auth) RequireApplicationToken(ctx *gin.Context) {
7575
a.abort401(ctx)
7676
}
7777

78+
// RequireAny requires client, application, or basic auth.
79+
func (a *Auth) RequireApplicationOrClient(ctx *gin.Context) {
80+
a.evaluateOr401(ctx, a.handleApplication, a.handleClient(), a.handleUser())
81+
}
82+
7883
func (a *Auth) Optional(ctx *gin.Context) {
7984
if !a.evaluate(ctx, a.handleUser(), a.handleClient()) {
8085
ctx.Next()

docs/spec.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1438,9 +1438,21 @@
14381438
},
14391439
{
14401440
"appTokenQuery": []
1441+
},
1442+
{
1443+
"clientTokenAuthorizationHeader": []
1444+
},
1445+
{
1446+
"clientTokenHeader": []
1447+
},
1448+
{
1449+
"clientTokenQuery": []
1450+
},
1451+
{
1452+
"basicAuth": []
14411453
}
14421454
],
1443-
"description": "__NOTE__: This API ONLY accepts an application token as authentication.",
1455+
"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.",
14441456
"consumes": [
14451457
"application/json"
14461458
],

router/router.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
188188
ctx.JSON(200, &model.GotifyInfo{Version: vInfo.Version, Oidc: conf.OIDC.Enabled, Register: conf.Registration})
189189
})
190190

191-
g.Group("/").Use(authentication.RequireApplicationToken).POST("/message", messageHandler.CreateMessage)
191+
g.Group("/").Use(authentication.RequireApplicationOrClient).POST("/message", messageHandler.CreateMessage)
192192

193193
clientAuth := g.Group("")
194194
{

router/router_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ func (s *IntegrationSuite) TestAuthentication() {
385385

386386
req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup"}`)
387387
req.SetBasicAuth("normal", "secret")
388-
doRequestAndExpect(s.T(), req, 403, forbiddenJSON)
388+
doRequestAndExpect(s.T(), req, 400, `{"error":"Bad Request", "errorCode":400, "errorDescription":"appid is required when not authenticating with an application token"}`)
389389

390390
req = s.newRequest("GET", "current/user", "")
391391
req.SetBasicAuth("normal", "secret")

ui/src/message/MessagesStore.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,14 @@ export class MessagesStore {
141141
priority: number
142142
): Promise<void> => {
143143
const app = this.appStore.getByID(appId);
144-
const payload: Pick<IMessage, 'title' | 'message' | 'priority'> = {
144+
const payload: Pick<IMessage, 'appid' | 'title' | 'message' | 'priority'> = {
145+
appid: appId,
145146
message,
146147
priority,
147148
title,
148149
};
149150

150-
await axios.post(`${config.get('url')}message`, payload, {
151-
headers: {'X-Gotify-Key': app.token},
152-
});
151+
await axios.post(`${config.get('url')}message`, payload);
153152
this.snack(`Message sent to ${app.name}`);
154153
};
155154

0 commit comments

Comments
 (0)