From 131aa218a7ce70cb496490228400c9f54a19d272 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Tue, 2 Jun 2026 19:25:40 +0800 Subject: [PATCH 1/7] feat: EdDSA token for database leakage/index mitigation --- api/application.go | 9 +- api/application_test.go | 128 ++++++------ api/client.go | 9 +- api/client_test.go | 70 ++++--- api/message.go | 2 +- api/message_test.go | 10 +- api/oidc.go | 9 +- api/oidc_test.go | 11 +- api/session.go | 10 +- api/session_test.go | 8 +- api/stream/client.go | 2 +- api/stream/stream_test.go | 4 +- api/tokens_test.go | 11 +- api/user.go | 2 +- auth/authentication.go | 18 +- auth/token.go | 183 +++++++++++++++--- auth/token_test.go | 51 +++-- database/database.go | 2 +- database/user.go | 2 +- model/message.go | 2 +- plugin/compat/instance.go | 14 +- plugin/compat/v1.go | 4 +- plugin/compat/v1_test.go | 4 +- plugin/compat/wrap_test.go | 1 - plugin/compat/wrap_test_norace.go | 1 - plugin/compat/wrap_test_race.go | 1 - plugin/example/echo/echo.go | 6 +- plugin/manager.go | 15 +- plugin/manager_test.go | 5 +- plugin/manager_test_norace.go | 1 - plugin/manager_test_race.go | 1 - .../broken/malformedconstructor/main.go | 2 +- plugin/testing/mock/mock.go | 13 +- router/router_test.go | 2 + runner/runner.go | 4 +- test/asserts.go | 4 +- test/asserts_test.go | 2 +- test/token.go | 16 -- test/token_test.go | 15 -- 39 files changed, 381 insertions(+), 273 deletions(-) delete mode 100644 test/token.go delete mode 100644 test/token_test.go diff --git a/api/application.go b/api/application.go index a984b0f7f..d6c987c43 100644 --- a/api/application.go +++ b/api/application.go @@ -92,12 +92,13 @@ type ApplicationParams struct { func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { + tokenPublic, tokenPrivate := generateApplicationToken() app := model.Application{ Name: applicationParams.Name, Description: applicationParams.Description, DefaultPriority: applicationParams.DefaultPriority, SortKey: applicationParams.SortKey, - Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists), + Token: tokenPublic, UserID: auth.GetUserID(ctx), Internal: false, } @@ -106,6 +107,7 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { handleApplicationError(ctx, err) return } + app.Token = tokenPrivate ctx.JSON(200, withResolvedImage(&app)) } } @@ -452,11 +454,6 @@ func withResolvedImage(app *model.Application) *model.Application { return app } -func (a *ApplicationAPI) applicationExists(token string) bool { - app, _ := a.DB.GetApplicationByToken(token) - return app != nil -} - func exist(path string) bool { if _, err := os.Stat(path); os.IsNotExist(err) { return false diff --git a/api/application_test.go b/api/application_test.go index c0263c683..2fa7a1d34 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" @@ -22,12 +23,6 @@ import ( "github.com/stretchr/testify/suite" ) -var ( - firstApplicationToken = "Aaaaaaaaaaaaaaa" - secondApplicationToken = "Abbbbbbbbbbbbbb" - thirdApplicationToken = "Acccccccccccccc" -) - func TestApplicationSuite(t *testing.T) { suite.Run(t, new(ApplicationSuite)) } @@ -37,30 +32,23 @@ type ApplicationSuite struct { db *testdb.Database a *ApplicationAPI ctx *gin.Context + imageDir *test.TmpDir recorder *httptest.ResponseRecorder } -var ( - originalGenerateApplicationToken func() string - originalGenerateImageName func() string -) - func (s *ApplicationSuite) BeforeTest(suiteName, testName string) { - originalGenerateApplicationToken = generateApplicationToken - originalGenerateImageName = generateImageName - generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken) - generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:]) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) + tmpDir := test.NewTmpDir("gotify_applicationsuite") + s.imageDir = &tmpDir withURL(s.ctx, "http", "example.com") - s.a = &ApplicationAPI{DB: s.db} + s.a = &ApplicationAPI{DB: s.db, ImageDir: s.imageDir.Path() + "/"} } func (s *ApplicationSuite) AfterTest(suiteName, testName string) { - generateApplicationToken = originalGenerateApplicationToken - generateImageName = originalGenerateImageName + s.imageDir.Clean() s.db.Close() } @@ -73,7 +61,6 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { expected := &model.Application{ ID: 1, - Token: firstApplicationToken, UserID: 5, Name: "custom_name", Description: "description_text", @@ -82,6 +69,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + expected.Token = app.Token assert.Equal(s.T(), expected, app) } } @@ -133,7 +121,6 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar expected := &model.Application{ ID: 1, - Token: firstApplicationToken, Name: "name", Description: "description", Internal: false, @@ -143,7 +130,17 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar } assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Application + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), app.Token, tokenParsed.PublicForm()) + } } func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { @@ -167,10 +164,18 @@ func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() { s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0", CreatedAt: testdb.Now} + expected := &model.Application{ID: 1, Name: "custom_name", SortKey: "a0", CreatedAt: testdb.Now, Image: "static/defaultapp.png"} assert.Equal(s.T(), 200, s.recorder.Code) - if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), app, expected) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Application + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), app.Token, tokenParsed.PublicForm()) } } @@ -184,29 +189,39 @@ func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() { expected := &model.Application{ ID: 1, - Token: firstApplicationToken, Name: "custom_name", Image: "static/defaultapp.png", SortKey: "a0", CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Application + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), app.Token, tokenParsed.PublicForm()) + } } func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() { s.db.User(5) - s.db.User(6).AppWithToken(1, firstApplicationToken) + s.db.User(6).App(1) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0", CreatedAt: testdb.Now} + expected := &model.Application{ID: 2, Name: "custom_name", UserID: 5, SortKey: "a0", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), app, expected) + if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { + expected.Token = app.Token + assert.Equal(s.T(), expected, app) } } @@ -288,7 +303,7 @@ func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() { s.db.User(5).InternalApp(10) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "10"}} s.a.DeleteApplication(s.ctx) @@ -300,7 +315,7 @@ func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "4"}} s.a.DeleteApplication(s.ctx) @@ -312,7 +327,7 @@ func (s *ApplicationSuite) Test_DeleteApplication() { s.db.User(5).App(1) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DeleteApplication(s.ctx) @@ -371,7 +386,7 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { imgName := app.Image assert.Equal(s.T(), 200, s.recorder.Code) - _, err = os.Stat(imgName) + _, err = os.Stat(s.imageDir.Path(imgName)) assert.Nil(s.T(), err) s.a.DeleteApplication(s.ctx) @@ -381,12 +396,11 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { } } -func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() { - existingImageName := "2lHMAel6BDHLL-HrwphcviX-l.png" - firstGeneratedImageName := firstApplicationToken[1:] + ".png" - secondGeneratedImageName := secondApplicationToken[1:] + ".png" +func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImage() { + existingImageName := "existing.png" s.db.User(5) s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName}) + fakeImage(s.T(), s.imageDir.Path(existingImageName)) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) assert.Nil(s.T(), err) @@ -394,42 +408,14 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageA s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} - fakeImage(s.T(), existingImageName) - fakeImage(s.T(), firstGeneratedImageName) s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) - _, err = os.Stat(existingImageName) - assert.True(s.T(), os.IsNotExist(err)) - - _, err = os.Stat(secondGeneratedImageName) - assert.Nil(s.T(), err) - assert.Nil(s.T(), os.Remove(secondGeneratedImageName)) - assert.Nil(s.T(), os.Remove(firstGeneratedImageName)) -} - -func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() { - s.db.User(5) - s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"}) - - fakeImage(s.T(), "existing.png") - cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) + listing, err := os.ReadDir(s.imageDir.Path()) assert.Nil(s.T(), err) - s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) - s.ctx.Request.Header.Set("Content-Type", cType) - test.WithUser(s.ctx, 5) - s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} - - s.a.UploadApplicationImage(s.ctx) - - assert.Equal(s.T(), 200, s.recorder.Code) - - _, err = os.Stat("existing.png") - assert.True(s.T(), os.IsNotExist(err)) - - os.Remove(firstApplicationToken[1:] + ".png") + assert.Len(s.T(), listing, 1) } func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { @@ -504,14 +490,14 @@ func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() { imageFile := "existing.png" s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile}) - fakeImage(s.T(), imageFile) + fakeImage(s.T(), s.imageDir.Path(imageFile)) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.RemoveApplicationImage(s.ctx) - _, err := os.Stat(imageFile) + _, err := os.Stat(s.imageDir.Path(imageFile)) assert.True(s.T(), os.IsNotExist(err)) assert.Equal(s.T(), 200, s.recorder.Code) @@ -672,7 +658,7 @@ func (s *ApplicationSuite) withFormData(formData string) { s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } -func (s *ApplicationSuite) withJSON(value interface{}) { +func (s *ApplicationSuite) withJSON(value any) { jsonVal, _ := json.Marshal(value) s.ctx.Request = httptest.NewRequest("POST", "/application", bytes.NewBuffer(jsonVal)) s.ctx.Request.Header.Set("Content-Type", "application/json") diff --git a/api/client.go b/api/client.go index bef5901eb..f81535fb9 100644 --- a/api/client.go +++ b/api/client.go @@ -150,9 +150,10 @@ func (a *ClientAPI) UpdateClient(ctx *gin.Context) { func (a *ClientAPI) CreateClient(ctx *gin.Context) { clientParams := ClientParams{} if err := ctx.Bind(&clientParams); err == nil { + tokenPublic, tokenPrivate := generateClientToken() client := model.Client{ Name: clientParams.Name, - Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + Token: tokenPublic, UserID: auth.GetUserID(ctx), } if clientParams.ExpiresAfterInactivitySeconds != nil { @@ -162,6 +163,7 @@ func (a *ClientAPI) CreateClient(ctx *gin.Context) { if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { return } + client.Token = tokenPrivate ctx.JSON(200, client) } } @@ -321,8 +323,3 @@ func (a *ClientAPI) ElevateClient(ctx *gin.Context) { ctx.Status(204) }) } - -func (a *ClientAPI) clientExists(token string) bool { - client, _ := a.DB.GetClientByToken(token) - return client != nil -} diff --git a/api/client_test.go b/api/client_test.go index 40422e14e..a55998cef 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "fmt" + "io" "net/http/httptest" "net/url" "strings" @@ -9,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" @@ -17,11 +20,6 @@ import ( "github.com/stretchr/testify/suite" ) -var ( - firstClientToken = "Caaaaaaaaaaaaaa" - secondClientToken = "Cbbbbbbbbbbbbbb" -) - func TestClientSuite(t *testing.T) { suite.Run(t, new(ClientSuite)) } @@ -35,11 +33,7 @@ type ClientSuite struct { notified bool } -var originalGenerateClientToken func() string - func (s *ClientSuite) BeforeTest(suiteName, testName string) { - originalGenerateClientToken = generateClientToken - generateClientToken = test.Tokens(firstClientToken, secondClientToken) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) @@ -54,7 +48,6 @@ func (s *ClientSuite) notify(uint, string) { } func (s *ClientSuite) AfterTest(suiteName, testName string) { - generateClientToken = originalGenerateClientToken s.db.Close() } @@ -71,10 +64,11 @@ func (s *ClientSuite) Test_CreateClient_mapAllParameters() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name", CreatedAt: testdb.Now} + expected := &model.Client{ID: 1, UserID: 5, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), clients, expected) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + expected.Token = client.Token + assert.Equal(s.T(), expected, client) } } @@ -85,11 +79,12 @@ func (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInParams() { s.withFormData("name=myclient&ID=45&Token=12341234&UserID=333") s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, Name: "myclient", CreatedAt: testdb.Now} + expected := &model.Client{ID: 1, UserID: 5, Name: "myclient", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), clients, expected) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + expected.Token = client.Token + assert.Equal(s.T(), expected, client) } } @@ -129,22 +124,44 @@ func (s *ClientSuite) Test_CreateClient_returnsClientWithID() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", CreatedAt: testdb.Now} + expected := &model.Client{ID: 1, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Client + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), client.Token, tokenParsed.PublicForm()) + } } func (s *ClientSuite) Test_CreateClient_withExistingToken() { - s.db.User(5).ClientWithToken(1, firstClientToken) + firstClientTokenPublic, _ := generateClientToken() + s.db.User(5).ClientWithToken(1, firstClientTokenPublic) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", CreatedAt: testdb.Now} + expected := &model.Client{ID: 2, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Client + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if client, err := s.db.GetClientByID(2); assert.NoError(s.T(), err) { + assert.Equal(s.T(), client.Token, tokenParsed.PublicForm()) + assert.NotEqual(s.T(), tokenParsed.PublicForm(), firstClientTokenPublic) + } } func (s *ClientSuite) Test_GetClients() { @@ -165,7 +182,7 @@ func (s *ClientSuite) Test_DeleteClient_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.AddParam("id", "8") s.a.DeleteClient(s.ctx) @@ -177,7 +194,7 @@ func (s *ClientSuite) Test_DeleteClient() { s.db.User(5).Client(8) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.AddParam("id", "8") assert.False(s.T(), s.notified) @@ -204,7 +221,7 @@ func (s *ClientSuite) Test_CreateClient_acceptsExpiresAfterInactivitySeconds() { } func (s *ClientSuite) Test_UpdateClient_updatesExpiresAfterInactivitySeconds() { - s.db.User(5).NewClientWithToken(1, firstClientToken) + s.db.User(5).Client(1) test.WithUser(s.ctx, 5) s.withFormData("name=firefox&expiresAfterInactivitySeconds=7200") @@ -218,7 +235,8 @@ func (s *ClientSuite) Test_UpdateClient_updatesExpiresAfterInactivitySeconds() { } func (s *ClientSuite) Test_UpdateClient_expectSuccess() { - s.db.User(5).NewClientWithToken(1, firstClientToken) + firstClientTokenPublic, _ := generateClientToken() + s.db.User(5).ClientWithToken(1, firstClientTokenPublic) test.WithUser(s.ctx, 5) s.withFormData("name=firefox") @@ -227,7 +245,7 @@ func (s *ClientSuite) Test_UpdateClient_expectSuccess() { expected := &model.Client{ ID: 1, - Token: firstClientToken, + Token: firstClientTokenPublic, UserID: 5, Name: "firefox", CreatedAt: testdb.Now, diff --git a/api/message.go b/api/message.go index e6a2199e0..185e31e9d 100644 --- a/api/message.go +++ b/api/message.go @@ -433,7 +433,7 @@ func toExternalMessage(msg *model.Message) *model.MessageExternal { Date: msg.Date, } if len(msg.Extras) != 0 { - res.Extras = make(map[string]interface{}) + res.Extras = make(map[string]any) json.Unmarshal(msg.Extras, &res.Extras) } return res diff --git a/api/message_test.go b/api/message_test.go index d2561b81f..2a1d4a2b9 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -53,9 +53,9 @@ func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() { actual := &model.PagedMessages{ Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"}, - Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{ + Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]any{ "test::string": "string", - "test::array": []interface{}{1, 2, 3}, + "test::array": []any{1, 2, 3}, "test::int": 1, "test::float": 0.5, }}}, @@ -461,10 +461,10 @@ func (s *MessageSuite) Test_CreateMessage_WithExtras() { Title: "msg with extras", Date: t, Priority: intPtr(0), - Extras: map[string]interface{}{ - "gotify::test": map[string]interface{}{ + Extras: map[string]any{ + "gotify::test": map[string]any{ "string": "test", - "array": []interface{}{float64(1), float64(2), float64(3)}, + "array": []any{float64(1), float64(2), float64(3)}, "int": float64(1), "float": float64(0.5), }, diff --git a/api/oidc.go b/api/oidc.go index 706d318f6..e1b02815a 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -423,14 +423,19 @@ func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) { func (a *OIDCAPI) createClient(name string, userID uint) (*model.Client, error) { elevatedUntil := time.Now().Add(model.DefaultElevationDuration) + tokenPublic, tokenPrivate := generateClientToken() client := &model.Client{ Name: name, - Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), + Token: tokenPublic, UserID: userID, ElevatedUntil: &elevatedUntil, ExpiresAfterInactivitySeconds: auth.CookieMaxAge, } - return client, a.DB.CreateClient(client) + if err := a.DB.CreateClient(client); err != nil { + return nil, err + } + client.Token = tokenPrivate + return client, nil } func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) { diff --git a/api/oidc_test.go b/api/oidc_test.go index 473e57f61..837704c05 100644 --- a/api/oidc_test.go +++ b/api/oidc_test.go @@ -10,15 +10,12 @@ import ( "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/decaymap" "github.com/gotify/server/v2/mode" - "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/zitadel/oidc/v3/pkg/oidc" ) -var origGenClientToken = generateClientToken - func TestOIDCSuite(t *testing.T) { suite.Run(t, new(OIDCSuite)) } @@ -144,19 +141,17 @@ func (s *OIDCSuite) Test_ResolveUser_CustomClaim() { // --- createClient --- func (s *OIDCSuite) Test_CreateClient() { - generateClientToken = test.Tokens("Ctesttoken00001") - defer func() { generateClientToken = origGenClientToken }() - s.db.NewUser(1) client, err := s.a.createClient("MyPhone", 1) assert.NoError(s.T(), err) assert.Equal(s.T(), "MyPhone", client.Name) - assert.Equal(s.T(), "Ctesttoken00001", client.Token) + tokenParsed, err := auth.ParseEnhancedToken(client.Token) + assert.NoError(s.T(), err) assert.Equal(s.T(), uint(1), client.UserID) assert.Equal(s.T(), uint(auth.CookieMaxAge), client.ExpiresAfterInactivitySeconds) - dbClient, err := s.db.GetClientByToken("Ctesttoken00001") + dbClient, err := s.db.GetClientByToken(tokenParsed.PublicForm()) assert.NoError(s.T(), err) assert.NotNil(s.T(), dbClient) } diff --git a/api/session.go b/api/session.go index c72b8f551..924245351 100644 --- a/api/session.go +++ b/api/session.go @@ -76,9 +76,10 @@ func (a *SessionAPI) Login(ctx *gin.Context) { } elevatedUntil := time.Now().Add(model.DefaultElevationDuration) + tokenPublic, tokenPrivate := generateClientToken() client := model.Client{ Name: clientParams.Name, - Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + Token: tokenPublic, UserID: user.ID, ElevatedUntil: &elevatedUntil, ExpiresAfterInactivitySeconds: auth.CookieMaxAge, @@ -87,7 +88,7 @@ func (a *SessionAPI) Login(ctx *gin.Context) { return } - auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge, a.SecureCookie) + auth.SetCookie(ctx.Writer, tokenPrivate, auth.CookieMaxAge, a.SecureCookie) ctx.JSON(200, &model.CurrentUserExternal{ ID: user.ID, @@ -138,8 +139,3 @@ func (a *SessionAPI) Logout(ctx *gin.Context) { ctx.Status(200) } - -func (a *SessionAPI) clientExists(token string) bool { - client, _ := a.DB.GetClientByToken(token) - return client != nil -} diff --git a/api/session_test.go b/api/session_test.go index ebe3b6f33..fea0bbd6c 100644 --- a/api/session_test.go +++ b/api/session_test.go @@ -12,7 +12,6 @@ import ( "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" - "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -55,10 +54,6 @@ func (s *SessionSuite) AfterTest(suiteName, testName string) { } func (s *SessionSuite) Test_Login_Success() { - originalGenerateClientToken := generateClientToken - defer func() { generateClientToken = originalGenerateClientToken }() - generateClientToken = test.Tokens("Ctesttoken12345") - s.ctx.Request = httptest.NewRequest("POST", "/auth/local/login", strings.NewReader("name=test-browser")) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.ctx.Request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))) @@ -77,7 +72,8 @@ func (s *SessionSuite) Test_Login_Success() { } } assert.NotNil(s.T(), sessionCookie) - assert.Equal(s.T(), "Ctesttoken12345", sessionCookie.Value) + _, err := auth.ParseEnhancedToken(sessionCookie.Value) + assert.NoError(s.T(), err) assert.True(s.T(), sessionCookie.HttpOnly) assert.Equal(s.T(), "/", sessionCookie.Path) assert.Equal(s.T(), http.SameSiteStrictMode, sessionCookie.SameSite) diff --git a/api/stream/client.go b/api/stream/client.go index 1d7f4ba0d..fbb8b13b6 100644 --- a/api/stream/client.go +++ b/api/stream/client.go @@ -16,7 +16,7 @@ var ping = func(conn *websocket.Conn) error { return conn.WriteMessage(websocket.PingMessage, nil) } -var writeJSON = func(conn *websocket.Conn, v interface{}) error { +var writeJSON = func(conn *websocket.Conn, v any) error { return conn.WriteJSON(v) } diff --git a/api/stream/stream_test.go b/api/stream/stream_test.go index a15fd8d01..9b644325d 100644 --- a/api/stream/stream_test.go +++ b/api/stream/stream_test.go @@ -40,7 +40,7 @@ func TestWriteMessageFails(t *testing.T) { mode.Set(mode.TestDev) oldWrite := writeJSON // try emulate an write error, mostly this should kill the ReadMessage goroutine first but you'll never know. - writeJSON = func(conn *websocket.Conn, v interface{}) error { + writeJSON = func(conn *websocket.Conn, v any) error { return errors.New("asd") } defer func() { @@ -610,7 +610,7 @@ func staticUserID() gin.HandlerFunc { } func waitForConnectedClients(api *API, count int) { - for i := 0; i < 10; i++ { + for range 10 { if countClients(api) == count { // ok return diff --git a/api/tokens_test.go b/api/tokens_test.go index 66289e751..41c671095 100644 --- a/api/tokens_test.go +++ b/api/tokens_test.go @@ -8,7 +8,12 @@ import ( ) func TestTokenGeneration(t *testing.T) { - assert.Regexp(t, regexp.MustCompile("^C(.+)$"), generateClientToken()) - assert.Regexp(t, regexp.MustCompile("^A(.+)$"), generateApplicationToken()) - assert.Regexp(t, regexp.MustCompile("^(.+)$"), generateImageName()) + clientPub, clientPriv := generateClientToken() + assert.Regexp(t, regexp.MustCompile(`^gtfy_client\.(.+)$`), clientPub) + assert.Regexp(t, regexp.MustCompile(`^gtfy_client\.(.+)$`), clientPriv) + applicationPub, applicationPriv := generateApplicationToken() + assert.Regexp(t, regexp.MustCompile(`^gtfy_app\.(.+)$`), applicationPub) + assert.Regexp(t, regexp.MustCompile(`^gtfy_app\.(.+)$`), applicationPriv) + imageName := generateImageName() + assert.Regexp(t, regexp.MustCompile(`^(.+)$`), imageName) } diff --git a/api/user.go b/api/user.go index e24714e63..e1d393a4e 100644 --- a/api/user.go +++ b/api/user.go @@ -20,7 +20,7 @@ type UserDatabase interface { DeleteUserByID(id uint) error UpdateUser(user *model.User) error CreateUser(user *model.User) error - CountUser(condition ...interface{}) (int64, error) + CountUser(condition ...any) (int64, error) } // UserChangeNotifier notifies listeners for user changes. diff --git a/auth/authentication.go b/auth/authentication.go index 2d52e2146..532cd18a8 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -151,6 +151,13 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu if token == "" { return authStateSkip, nil } + if strings.HasPrefix(token, enhancedTokenPrefix) { + complexToken, err := ParseEnhancedToken(token) + if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) { + return authStateSkip, err + } + token = complexToken.PublicForm() + } client, err := a.DB.GetClientByToken(token) if err != nil { return authStateSkip, err @@ -166,7 +173,7 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, client.Token, CookieMaxAge, a.SecureCookie) + SetCookie(ctx.Writer, token, CookieMaxAge, a.SecureCookie) } } @@ -185,6 +192,13 @@ func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { if token == "" { return authStateSkip, nil } + if strings.HasPrefix(token, enhancedTokenPrefix) { + complexToken, err := ParseEnhancedToken(token) + if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) { + return authStateSkip, err + } + token = complexToken.PublicForm() + } app, err := a.DB.GetApplicationByToken(token) if err != nil { return authStateSkip, err @@ -200,7 +214,7 @@ func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, app.Token, CookieMaxAge, a.SecureCookie) + SetCookie(ctx.Writer, token, CookieMaxAge, a.SecureCookie) } } diff --git a/auth/token.go b/auth/token.go index a0f9e0b5f..3d9ca9b30 100644 --- a/auth/token.go +++ b/auth/token.go @@ -1,20 +1,167 @@ package auth import ( + "crypto" + "crypto/ed25519" "crypto/rand" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" "math/big" + "strconv" + "strings" +) + +const ( + maxTimestampDiffSeconds = 15 * 60 + randomTokenLength = 22 // ~2^132 keyspace ) var ( - tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") - randomTokenLength = 22 // ~2^132.5 keyspace (7.65e+39) - applicationPrefix = "A" - clientPrefix = "C" - pluginPrefix = "P" + errInvalidToken = errors.New("invalid token") + errNoPrivateKey = errors.New("no private key") + tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + pluginPrefix = "P" + enhancedTokenPrefix = "gtfy_" randReader = rand.Reader ) +type EnhancedToken struct { + ident string // a shared identifier, in formats like A12 for application ID 12 + pubOrPrivKey []byte // public key + timestamp int64 + signature []byte +} + +// PublicForm returns the a canonicalized representation of the public key. +func (c *EnhancedToken) PublicForm() string { + if c.timestamp != 0 || len(c.signature) != 0 { + return enhancedTokenPrefix + c.ident + "." + base64.RawURLEncoding.EncodeToString(c.pubOrPrivKey) + } + privKey := ed25519.NewKeyFromSeed(c.pubOrPrivKey) + return enhancedTokenPrefix + c.ident + "." + base64.RawURLEncoding.EncodeToString(privKey.Public().(ed25519.PublicKey)) +} + +// Sign signs the timestamp with the private key and returns a new EnhancedToken. +func (c *EnhancedToken) Sign(timestamp int64) (*EnhancedToken, error) { + if c.timestamp != 0 || len(c.signature) != 0 || len(c.pubOrPrivKey) != ed25519.SeedSize { + return nil, errNoPrivateKey + } + privKey := ed25519.NewKeyFromSeed(c.pubOrPrivKey) + sha512 := sha512.New() + sha512.Write([]byte("iss=")) + fmt.Fprintf(sha512, "%d", timestamp) + sign, err := privKey.Sign(nil, sha512.Sum(nil), crypto.SHA512) + if err != nil { + return nil, err + } + return &EnhancedToken{ + ident: c.ident, + pubOrPrivKey: privKey.Public().(ed25519.PublicKey), + timestamp: timestamp, + signature: sign, + }, nil +} + +func (c *EnhancedToken) ValidateTimestamp(now int64) bool { + if c.timestamp == 0 && len(c.signature) == 0 { + return true + } + if c.timestamp < now-maxTimestampDiffSeconds { + return false + } + if c.timestamp > now+maxTimestampDiffSeconds { + return false + } + return true +} + +// String marshals the token into a string. +func (c *EnhancedToken) String() string { + var b strings.Builder + b.WriteString(enhancedTokenPrefix) + b.WriteString(c.ident) + b.WriteByte('.') + b.WriteString(base64.RawURLEncoding.EncodeToString(c.pubOrPrivKey)) + if c.timestamp != 0 || len(c.signature) != 0 { + fmt.Fprintf(&b, ".%d.", c.timestamp) + b.WriteString(base64.RawURLEncoding.EncodeToString(c.signature)) + } + return b.String() +} + +// NewEnhancedToken creates a new EnhancedToken. +func NewEnhancedToken(ident string) *EnhancedToken { + ident = strings.ReplaceAll(ident, ".", "_") + var seed [ed25519.SeedSize]byte + _, err := rand.Read(seed[:]) + if err != nil { + panic("unreachable: random source should never return an error") + } + return &EnhancedToken{ident: ident, pubOrPrivKey: seed[:]} +} + +// ParseEnhancedToken parses a string into an EnhancedToken. +func ParseEnhancedToken(token string) (*EnhancedToken, error) { + token, found := strings.CutPrefix(token, enhancedTokenPrefix) + if !found { + return nil, errInvalidToken + } + + // count number of dots, one dot -> ident then private key, three dots -> ident, public key, challenge then signature + fields := strings.SplitN(token, ".", 4) + if len(fields) != 2 && len(fields) != 4 { + return nil, errInvalidToken + } + ident := fields[0] + pkOrPubkeyB64 := fields[1] + pkOrPubkeyBytesLen := base64.RawURLEncoding.DecodedLen(len(pkOrPubkeyB64)) + pkOrPubkey, err := base64.RawURLEncoding.DecodeString(pkOrPubkeyB64) + if err != nil { + return nil, errInvalidToken + } + if len(fields) == 2 { + if pkOrPubkeyBytesLen != ed25519.SeedSize { + return nil, errInvalidToken + } + return &EnhancedToken{ + ident: ident, + pubOrPrivKey: pkOrPubkey, + }, nil + } + if pkOrPubkeyBytesLen != ed25519.PublicKeySize { + return nil, errInvalidToken + } + timestampStr := fields[2] + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + return nil, errInvalidToken + } + signatureB64 := fields[3] + signatureBytesLen := base64.RawURLEncoding.DecodedLen(len(signatureB64)) + if signatureBytesLen != ed25519.SignatureSize { + return nil, errInvalidToken + } + signature, err := base64.RawURLEncoding.DecodeString(signatureB64) + if err != nil { + return nil, errInvalidToken + } + sha512 := sha512.New() + sha512.Write([]byte("iss=")) // query-like encoding to give us some semantic headroom should we need more fields in the future + fmt.Fprintf(sha512, "%d", timestamp) + if err := ed25519.VerifyWithOptions(pkOrPubkey, sha512.Sum(nil), signature, &ed25519.Options{Hash: crypto.SHA512}); err != nil { + return nil, errInvalidToken + } + return &EnhancedToken{ + ident: ident, + pubOrPrivKey: pkOrPubkey, + timestamp: timestamp, + signature: signature, + }, nil +} + func randIntn(n int) int { max := big.NewInt(int64(n)) res, err := rand.Int(randReader, max) @@ -24,29 +171,21 @@ func randIntn(n int) int { return int(res.Int64()) } -// GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token. -func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { - for { - token := generateToken() - if !tokenExists(token) { - return token - } - } -} - // GenerateApplicationToken generates an application token. -func GenerateApplicationToken() string { - return generateRandomToken(applicationPrefix) +func GenerateApplicationToken() (publicForm, privateForm string) { + token := NewEnhancedToken("app") + return token.PublicForm(), token.String() } // GenerateClientToken generates a client token. -func GenerateClientToken() string { - return generateRandomToken(clientPrefix) +func GenerateClientToken() (publicForm, privateForm string) { + token := NewEnhancedToken("client") + return token.PublicForm(), token.String() } // GeneratePluginToken generates a plugin token. func GeneratePluginToken() string { - return generateRandomToken(pluginPrefix) + return pluginPrefix + generateRandomString(randomTokenLength) } // GenerateImageName generates an image name. @@ -54,10 +193,6 @@ func GenerateImageName() string { return generateRandomString(25) } -func generateRandomToken(prefix string) string { - return prefix + generateRandomString(randomTokenLength) -} - func generateRandomString(length int) string { res := make([]byte, length) for i := range res { diff --git a/auth/token_test.go b/auth/token_test.go index 2b7c1ccca..c88ce168d 100644 --- a/auth/token_test.go +++ b/auth/token_test.go @@ -3,32 +3,45 @@ package auth import ( "crypto/rand" "errors" - "fmt" - "strings" + "log" "testing" "testing/iotest" "github.com/stretchr/testify/assert" ) -func TestTokenHavePrefix(t *testing.T) { - for i := 0; i < 50; i++ { - assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) - assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) - assert.True(t, strings.HasPrefix(GeneratePluginToken(), "P")) - assert.NotEmpty(t, GenerateImageName()) - } -} +func TestNewComplexToken(t *testing.T) { + token := NewEnhancedToken("A12") + log.Printf("token: %s", token.String()) + canonicalizedExpected := token.PublicForm() + tokenParsed, err := ParseEnhancedToken(token.String()) + assert.NoError(t, err) + tokenSigned, err := tokenParsed.Sign(12345) + assert.NoError(t, err) + tokenSignedStr := tokenSigned.String() + assert.True(t, tokenSigned.ValidateTimestamp(12345+1)) + assert.False(t, tokenSigned.ValidateTimestamp(12345+maxTimestampDiffSeconds+1)) + canonicalizedActual := tokenSigned.PublicForm() + assert.Equal(t, canonicalizedExpected, canonicalizedActual) -func TestGenerateNotExistingToken(t *testing.T) { - count := 5 - token := GenerateNotExistingToken(func() string { - return fmt.Sprint(count) - }, func(token string) bool { - count-- - return token != "0" - }) - assert.Equal(t, "0", token) + tokenParsed, err = ParseEnhancedToken(tokenSignedStr) + assert.NoError(t, err) + canonicalizedActual = tokenParsed.PublicForm() + assert.Equal(t, canonicalizedExpected, canonicalizedActual) + _, err = tokenSigned.Sign(12345) + assert.ErrorIs(t, err, errNoPrivateKey) + tokenParsed, err = ParseEnhancedToken(tokenSignedStr) + assert.NoError(t, err) + canonicalizedActual = tokenParsed.PublicForm() + assert.Equal(t, canonicalizedExpected, canonicalizedActual) + tokenSignedStrMutated := tokenSignedStr[1:] + if tokenSignedStr[0] != 'A' { + tokenSignedStrMutated = "A" + tokenSignedStrMutated + } else { + tokenSignedStrMutated = "B" + tokenSignedStrMutated + } + _, err = ParseEnhancedToken(tokenSignedStrMutated) + assert.ErrorIs(t, err, errInvalidToken) } func TestBadCryptoReaderPanics(t *testing.T) { diff --git a/database/database.go b/database/database.go index 802f56d3a..205489ac7 100644 --- a/database/database.go +++ b/database/database.go @@ -23,7 +23,7 @@ import ( // gormLogWriter routes gorm logger output through zerolog. type gormLogWriter struct{} -func (gormLogWriter) Printf(format string, args ...interface{}) { +func (gormLogWriter) Printf(format string, args ...any) { log.Warn().Str("component", "gorm").Msgf(format, args...) } diff --git a/database/user.go b/database/user.go index 8e6bab358..d625db692 100644 --- a/database/user.go +++ b/database/user.go @@ -32,7 +32,7 @@ func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) { } // CountUser returns the user count which satisfies the given condition. -func (d *GormDatabase) CountUser(condition ...interface{}) (int64, error) { +func (d *GormDatabase) CountUser(condition ...any) (int64, error) { c := int64(-1) handle := d.DB.Model(new(model.User)) if len(condition) == 1 { diff --git a/model/message.go b/model/message.go index e00545a21..57a6c9f9f 100644 --- a/model/message.go +++ b/model/message.go @@ -56,7 +56,7 @@ type MessageExternal struct { // These namespaces are reserved and might be used in the official clients: gotify android ios web server client. Do not use them for other purposes. // // example: {"home::appliances::thermostat::change_temperature":{"temperature":23},"home::appliances::lighting::on":{"brightness":15}} - Extras map[string]interface{} `form:"-" query:"-" json:"extras,omitempty"` + Extras map[string]any `form:"-" query:"-" json:"extras,omitempty"` // The date the message was created. // // read only: true diff --git a/plugin/compat/instance.go b/plugin/compat/instance.go index c3e71ce3b..d310216a2 100644 --- a/plugin/compat/instance.go +++ b/plugin/compat/instance.go @@ -2,6 +2,7 @@ package compat import ( "net/url" + "slices" "github.com/gin-gonic/gin" ) @@ -31,9 +32,9 @@ type PluginInstance interface { GetDisplay(location *url.URL) string // DefaultConfig see Configurer - DefaultConfig() interface{} + DefaultConfig() any // ValidateAndSetConfig see Configurer - ValidateAndSetConfig(c interface{}) error + ValidateAndSetConfig(c any) error // SetMessageHandler see Messenger#SetMessageHandler SetMessageHandler(h MessageHandler) @@ -50,12 +51,7 @@ type PluginInstance interface { // HasSupport tests a PluginInstance for a capability. func HasSupport(p PluginInstance, toCheck Capability) bool { - for _, module := range p.Supports() { - if module == toCheck { - return true - } - } - return false + return slices.Contains(p.Supports(), toCheck) } // Capabilities is a slice of module. @@ -87,5 +83,5 @@ type Message struct { Message string Title string Priority int - Extras map[string]interface{} + Extras map[string]any } diff --git a/plugin/compat/v1.go b/plugin/compat/v1.go index 86832c3c6..1b3e99ad2 100644 --- a/plugin/compat/v1.go +++ b/plugin/compat/v1.go @@ -77,7 +77,7 @@ type PluginV1Instance struct { } // DefaultConfig see papiv1.Configurer. -func (c *PluginV1Instance) DefaultConfig() interface{} { +func (c *PluginV1Instance) DefaultConfig() any { if c.configurer != nil { return c.configurer.DefaultConfig() } @@ -85,7 +85,7 @@ func (c *PluginV1Instance) DefaultConfig() interface{} { } // ValidateAndSetConfig see papiv1.Configurer. -func (c *PluginV1Instance) ValidateAndSetConfig(config interface{}) error { +func (c *PluginV1Instance) ValidateAndSetConfig(config any) error { if c.configurer != nil { return c.configurer.ValidateAndSetConfig(config) } diff --git a/plugin/compat/v1_test.go b/plugin/compat/v1_test.go index a516e0341..7e5db7aec 100644 --- a/plugin/compat/v1_test.go +++ b/plugin/compat/v1_test.go @@ -118,7 +118,7 @@ func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() { Title: "test message", Message: "test", Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "test::string": "test", }, } @@ -127,7 +127,7 @@ func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() { Title: "test message", Message: "test", Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "test::string": "test", }, }, handler.msgSent) diff --git a/plugin/compat/wrap_test.go b/plugin/compat/wrap_test.go index b3a4433fc..944277a22 100644 --- a/plugin/compat/wrap_test.go +++ b/plugin/compat/wrap_test.go @@ -1,5 +1,4 @@ //go:build linux || darwin -// +build linux darwin package compat diff --git a/plugin/compat/wrap_test_norace.go b/plugin/compat/wrap_test_norace.go index c3ee4a53a..62963f0de 100644 --- a/plugin/compat/wrap_test_norace.go +++ b/plugin/compat/wrap_test_norace.go @@ -1,5 +1,4 @@ //go:build !race -// +build !race package compat diff --git a/plugin/compat/wrap_test_race.go b/plugin/compat/wrap_test_race.go index b5668b90b..c2cfceb8b 100644 --- a/plugin/compat/wrap_test_race.go +++ b/plugin/compat/wrap_test_race.go @@ -1,5 +1,4 @@ //go:build race -// +build race package compat diff --git a/plugin/example/echo/echo.go b/plugin/example/echo/echo.go index b8b8407d2..db1784fab 100644 --- a/plugin/example/echo/echo.go +++ b/plugin/example/echo/echo.go @@ -47,14 +47,14 @@ type Config struct { } // DefaultConfig implements plugin.Configurer -func (c *EchoPlugin) DefaultConfig() interface{} { +func (c *EchoPlugin) DefaultConfig() any { return &Config{ MagicString: "hello world", } } // ValidateAndSetConfig implements plugin.Configurer -func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error { +func (c *EchoPlugin) ValidateAndSetConfig(config any) error { c.config = config.(*Config) return nil } @@ -86,7 +86,7 @@ func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) { Title: "Hello received", Message: fmt.Sprintf("echo server received a hello message %d times", conf.CalledTimes), Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "plugin::name": "echo", }, }) diff --git a/plugin/manager.go b/plugin/manager.go index 9c9436dcf..5048f2328 100644 --- a/plugin/manager.go +++ b/plugin/manager.go @@ -103,16 +103,6 @@ func NewManager(db Database, directory string, mux *gin.RouterGroup, notifier No // ErrAlreadyEnabledOrDisabled is returned on SetPluginEnabled call when a plugin is already enabled or disabled. var ErrAlreadyEnabledOrDisabled = errors.New("config is already enabled/disabled") -func (m *Manager) applicationExists(token string) bool { - app, _ := m.db.GetApplicationByToken(token) - return app != nil -} - -func (m *Manager) pluginConfExists(token string) bool { - pluginConf, _ := m.db.GetPluginConfByToken(token) - return pluginConf != nil -} - // SetPluginEnabled sets the plugins enabled state. func (m *Manager) SetPluginEnabled(pluginID uint, enabled bool) error { instance, err := m.Instance(pluginID) @@ -403,14 +393,15 @@ func (m *Manager) createPluginConf(instance compat.PluginInstance, info compat.I pluginConf := &model.PluginConf{ UserID: userID, ModulePath: info.ModulePath, - Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, m.pluginConfExists), + Token: auth.GeneratePluginToken(), } if compat.HasSupport(instance, compat.Configurer) { pluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig()) } if compat.HasSupport(instance, compat.Messenger) { + tokenPublic, _ := auth.GenerateApplicationToken() app := &model.Application{ - Token: auth.GenerateNotExistingToken(auth.GenerateApplicationToken, m.applicationExists), + Token: tokenPublic, Name: info.String(), UserID: userID, Internal: true, diff --git a/plugin/manager_test.go b/plugin/manager_test.go index 8d698966e..63811cffa 100644 --- a/plugin/manager_test.go +++ b/plugin/manager_test.go @@ -1,5 +1,4 @@ //go:build linux || darwin -// +build linux darwin package plugin @@ -306,13 +305,13 @@ func (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() { ModulePath: mockPluginPath, Enabled: true, UserID: 9, - Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), + Token: auth.GeneratePluginToken(), }) s.db.CreatePluginConf(&model.PluginConf{ ModulePath: examplePluginPath, Enabled: true, UserID: 9, - Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), + Token: auth.GeneratePluginToken(), }) assert.Nil(s.T(), s.manager.RemoveUser(9)) } diff --git a/plugin/manager_test_norace.go b/plugin/manager_test_norace.go index 5af219b62..3d92abd1f 100644 --- a/plugin/manager_test_norace.go +++ b/plugin/manager_test_norace.go @@ -1,5 +1,4 @@ //go:build !race -// +build !race package plugin diff --git a/plugin/manager_test_race.go b/plugin/manager_test_race.go index ff558d764..49d0a66ee 100644 --- a/plugin/manager_test_race.go +++ b/plugin/manager_test_race.go @@ -1,5 +1,4 @@ //go:build race -// +build race package plugin diff --git a/plugin/testing/broken/malformedconstructor/main.go b/plugin/testing/broken/malformedconstructor/main.go index c021cf377..f5f073e6f 100644 --- a/plugin/testing/broken/malformedconstructor/main.go +++ b/plugin/testing/broken/malformedconstructor/main.go @@ -25,7 +25,7 @@ func (c *Plugin) Disable() error { } // NewGotifyPluginInstance creates a plugin instance for a user context. -func NewGotifyPluginInstance(ctx plugin.UserContext) interface{} { +func NewGotifyPluginInstance(ctx plugin.UserContext) any { return &Plugin{} } diff --git a/plugin/testing/mock/mock.go b/plugin/testing/mock/mock.go index 10c748491..51c15d969 100644 --- a/plugin/testing/mock/mock.go +++ b/plugin/testing/mock/mock.go @@ -3,6 +3,7 @@ package mock import ( "errors" "net/url" + "slices" "github.com/gin-gonic/gin" @@ -119,10 +120,8 @@ func (c *PluginInstance) RegisterWebhook(basePath string, mux *gin.RouterGroup) // SetCapability changes the capability of this plugin func (c *PluginInstance) SetCapability(p compat.Capability, enable bool) { if enable { - for _, cap := range c.capabilities { - if cap == p { - return - } + if slices.Contains(c.capabilities, p) { + return } c.capabilities = append(c.capabilities, p) } else { @@ -143,7 +142,7 @@ func (c *PluginInstance) Supports() compat.Capabilities { } // DefaultConfig implements compat.Configuror -func (c *PluginInstance) DefaultConfig() interface{} { +func (c *PluginInstance) DefaultConfig() any { return &PluginConfig{ TestKey: "default", IsNotValid: false, @@ -151,7 +150,7 @@ func (c *PluginInstance) DefaultConfig() interface{} { } // ValidateAndSetConfig implements compat.Configuror -func (c *PluginInstance) ValidateAndSetConfig(config interface{}) error { +func (c *PluginInstance) ValidateAndSetConfig(config any) error { if (config.(*PluginConfig)).IsNotValid { return errors.New("conf is not valid") } @@ -170,7 +169,7 @@ func (c *PluginInstance) TriggerMessage() { Title: "test message", Message: "test", Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "test::string": "test", }, }) diff --git a/router/router_test.go b/router/router_test.go index 4ac7c1232..bf5d67dbb 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "log" "net/http" "net/http/httptest" "strings" @@ -332,6 +333,7 @@ func (s *IntegrationSuite) TestSendMessage() { token := &model.Application{} json.NewDecoder(res.Body).Decode(token) assert.Equal(s.T(), "backup-server", token.Name) + log.Printf("token: %s", token.Token) req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup done"}`) req.Header.Add("X-Gotify-Key", token.Token) diff --git a/runner/runner.go b/runner/runner.go index bee93dc87..32f90c77f 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -95,8 +95,8 @@ func startListening(connectionType, listenAddr string, port, keepAlive int) (net } func getNetworkAndAddr(listenAddr string, port int) (string, string) { - if strings.HasPrefix(listenAddr, "unix:") { - return "unix", strings.TrimPrefix(listenAddr, "unix:") + if after, ok := strings.CutPrefix(listenAddr, "unix:"); ok { + return "unix", after } return "tcp", fmt.Sprintf("%s:%d", listenAddr, port) } diff --git a/test/asserts.go b/test/asserts.go index 09042c090..1cb3b81de 100644 --- a/test/asserts.go +++ b/test/asserts.go @@ -11,7 +11,7 @@ import ( ) // BodyEquals asserts the content from the response recorder with the encoded json of the provided instance. -func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseRecorder) { +func BodyEquals(t assert.TestingT, obj any, recorder *httptest.ResponseRecorder) { bytes, err := io.ReadAll(recorder.Body) assert.Nil(t, err) actual := string(bytes) @@ -20,7 +20,7 @@ func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseR } // JSONEquals asserts the content of the string with the encoded json of the provided instance. -func JSONEquals(t assert.TestingT, obj interface{}, expected string) { +func JSONEquals(t assert.TestingT, obj any, expected string) { bytes, err := json.Marshal(obj) assert.Nil(t, err) objJSON := string(bytes) diff --git a/test/asserts_test.go b/test/asserts_test.go index a018ab968..ec90f38ea 100644 --- a/test/asserts_test.go +++ b/test/asserts_test.go @@ -18,7 +18,7 @@ type fakeTesting struct { hasErrors bool } -func (t *fakeTesting) Errorf(format string, args ...interface{}) { +func (t *fakeTesting) Errorf(format string, args ...any) { t.hasErrors = true } diff --git a/test/token.go b/test/token.go deleted file mode 100644 index 64370f472..000000000 --- a/test/token.go +++ /dev/null @@ -1,16 +0,0 @@ -package test - -import "sync" - -// Tokens returns a token generation function with takes a series of tokens and output them in order. -func Tokens(tokens ...string) func() string { - var i int - lock := sync.Mutex{} - return func() string { - lock.Lock() - defer lock.Unlock() - res := tokens[i%len(tokens)] - i++ - return res - } -} diff --git a/test/token_test.go b/test/token_test.go deleted file mode 100644 index 000be3d5e..000000000 --- a/test/token_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package test - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTokenGeneration(t *testing.T) { - mockTokenFunc := Tokens("a", "b", "c") - - for _, expected := range []string{"a", "b", "c", "a", "b", "c"} { - assert.Equal(t, expected, mockTokenFunc()) - } -} From a5da5b3babd5dfe9b43eade8234806ffcd2a29ef Mon Sep 17 00:00:00 2001 From: Yumechi Date: Sun, 14 Jun 2026 22:15:19 +0800 Subject: [PATCH 2/7] [skip ci]: remove token from api output --- api/application.go | 1 + api/application_test.go | 4 + api/client.go | 1 + api/client_test.go | 2 + auth/authentication.go | 6 +- ui/src/application/AddApplicationDialog.tsx | 116 ++++++++++++-------- ui/src/application/AppStore.ts | 5 +- ui/src/application/Applications.tsx | 5 - ui/src/client/AddClientDialog.tsx | 96 ++++++++++------ ui/src/client/ClientStore.ts | 5 +- ui/src/client/Clients.tsx | 11 -- ui/src/clipboard.ts | 17 +++ ui/src/common/CopyableSecret.tsx | 17 ++- 13 files changed, 174 insertions(+), 112 deletions(-) create mode 100644 ui/src/clipboard.ts diff --git a/api/application.go b/api/application.go index d6c987c43..3ff6112ff 100644 --- a/api/application.go +++ b/api/application.go @@ -143,6 +143,7 @@ func (a *ApplicationAPI) GetApplications(ctx *gin.Context) { return } for _, app := range apps { + app.Token = "" withResolvedImage(app) } ctx.JSON(200, apps) diff --git a/api/application_test.go b/api/application_test.go index 2fa7a1d34..4787cc6f2 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -278,6 +278,8 @@ func (s *ApplicationSuite) Test_GetApplications() { assert.Equal(s.T(), 200, s.recorder.Code) first.Image = "static/defaultapp.png" second.Image = "static/defaultapp.png" + first.Token = "" + second.Token = "" test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } @@ -296,6 +298,8 @@ func (s *ApplicationSuite) Test_GetApplications_WithImage() { assert.Equal(s.T(), 200, s.recorder.Code) first.Image = "image/abcd.jpg" second.Image = "static/defaultapp.png" + first.Token = "" + second.Token = "" test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } diff --git a/api/client.go b/api/client.go index f81535fb9..64af8a752 100644 --- a/api/client.go +++ b/api/client.go @@ -200,6 +200,7 @@ func (a *ClientAPI) GetClients(ctx *gin.Context) { } now := time.Now() for _, client := range clients { + client.Token = "" if client.ElevatedUntil != nil && !now.Before(*client.ElevatedUntil) { client.ElevatedUntil = nil } diff --git a/api/client_test.go b/api/client_test.go index a55998cef..585a2d64a 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -175,6 +175,8 @@ func (s *ClientSuite) Test_GetClients() { s.a.GetClients(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) + first.Token = "" + second.Token = "" test.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder) } diff --git a/auth/authentication.go b/auth/authentication.go index 532cd18a8..5da712d1f 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -148,6 +148,7 @@ func (a *Auth) handleUser(checks ...func(*model.User) (authState, error)) func(c func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) func(ctx *gin.Context) (authState, error) { return func(ctx *gin.Context) (authState, error) { token, isCookie := a.readTokenFromRequest(ctx) + originalToken := token if token == "" { return authStateSkip, nil } @@ -173,7 +174,7 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, token, CookieMaxAge, a.SecureCookie) + SetCookie(ctx.Writer, originalToken, CookieMaxAge, a.SecureCookie) } } @@ -189,6 +190,7 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { token, isCookie := a.readTokenFromRequest(ctx) + originalToken := token if token == "" { return authStateSkip, nil } @@ -214,7 +216,7 @@ func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, token, CookieMaxAge, a.SecureCookie) + SetCookie(ctx.Writer, originalToken, CookieMaxAge, a.SecureCookie) } } diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index 99d465ec7..e1485ed11 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -8,70 +8,96 @@ import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; import React, {useState} from 'react'; +import {Typography} from '@mui/material'; +import {copyToClipboard} from '../clipboard'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; + fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; } export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { + const [returnToken, setReturnToken] = useState(''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [defaultPriority, setDefaultPriority] = useState(0); const submitEnabled = name.length !== 0; - const submitAndClose = async () => { - await fOnSubmit(name, description, defaultPriority); - fClose(); + const submitAndNext = async () => { + const token = await fOnSubmit(name, description, defaultPriority); + setReturnToken(token); }; return ( Create an application - An application is allowed to send messages. - setName(e.target.value)} - fullWidth - /> - setDescription(e.target.value)} - fullWidth - multiline - /> - setDefaultPriority(value)} - fullWidth - /> + {returnToken ? ( + + Your token is: + + {returnToken} + + + ) : ( + <> + + An application is allowed to send messages. + + setName(e.target.value)} + fullWidth + /> + setDescription(e.target.value)} + fullWidth + multiline + /> + setDefaultPriority(value)} + fullWidth + /> + + )} - - -
- -
-
+ {returnToken ? ( + + ) : ( + + )} + {returnToken ? ( + + ) : ( + +
+ +
+
+ )}
); diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index f14eb6cce..9fbb5586f 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -88,14 +88,15 @@ export class AppStore extends BaseStore { name: string, description: string, defaultPriority: number - ): Promise => { - await axios.post(`${config.get('url')}application`, { + ): Promise => { + const response = await axios.post(`${config.get('url')}application`, { name, description, defaultPriority, }); await this.refresh(); this.snack('Application created'); + return response.data.token; }; public getName = (id: number): string => { diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index af44d01e9..a535b58c9 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -26,7 +26,6 @@ import {CSS} from '@dnd-kit/utilities'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; -import CopyableSecret from '../common/CopyableSecret'; import {AddApplicationDialog} from './AddApplicationDialog'; import * as config from '../config'; import {UpdateApplicationDialog} from './UpdateApplicationDialog'; @@ -124,7 +123,6 @@ const Applications = observer(() => { Name - Token Description Priority Last Used @@ -255,9 +253,6 @@ const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { {app.name} - - - {app.description} {app.defaultPriority} diff --git a/ui/src/client/AddClientDialog.tsx b/ui/src/client/AddClientDialog.tsx index 22cb320e2..7a5c066d3 100644 --- a/ui/src/client/AddClientDialog.tsx +++ b/ui/src/client/AddClientDialog.tsx @@ -7,59 +7,85 @@ import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; +import {DialogContentText, Typography} from '@mui/material'; +import {copyToClipboard} from '../clipboard'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, expiresAfterInactivitySeconds: number) => Promise; + fOnSubmit: (name: string, expiresAfterInactivitySeconds: number) => Promise; } const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { + const [returnToken, setReturnToken] = useState(''); const [name, setName] = useState(''); const [expiresAfter, setExpiresAfter] = useState(0); const submitEnabled = name.length !== 0; - const submitAndClose = async () => { - await fOnSubmit(name, Math.max(0, expiresAfter)); - fClose(); + const submitAndNext = async () => { + const token = await fOnSubmit(name, Math.max(0, expiresAfter)); + setReturnToken(token); }; return ( Create a client - setName(e.target.value)} - fullWidth - /> - setExpiresAfter(value)} - fullWidth - /> + {returnToken ? ( + + Your token is: + + {returnToken} + + + ) : ( + <> + setName(e.target.value)} + fullWidth + /> + setExpiresAfter(value)} + fullWidth + /> + + )} - - -
- -
-
+ {returnToken ? ( + + ) : ( + + )} + {returnToken ? ( + + ) : ( + +
+ +
+
+ )}
); diff --git a/ui/src/client/ClientStore.ts b/ui/src/client/ClientStore.ts index b96dff59c..115fd56bd 100644 --- a/ui/src/client/ClientStore.ts +++ b/ui/src/client/ClientStore.ts @@ -47,9 +47,10 @@ export class ClientStore extends BaseStore { }; @action - public create = async (name: string, expiresAfterInactivitySeconds = 0): Promise => { - await this.createNoNotifcation(name, expiresAfterInactivitySeconds); + public create = async (name: string, expiresAfterInactivitySeconds = 0): Promise => { + const client = await this.createNoNotifcation(name, expiresAfterInactivitySeconds); this.snack('Client added'); + return client.token; }; @action diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 4c4d33c38..0185768aa 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -18,7 +18,6 @@ import AddClientDialog from './AddClientDialog'; import UpdateClientDialog from './UpdateClientDialog'; import ElevateClientDialog from './ElevateClientDialog'; import {IClient} from '../types'; -import CopyableSecret from '../common/CopyableSecret'; import {LastUsedCell} from '../common/LastUsedCell'; import {formatDate} from '../common/TimeAgoFormatter'; import {RemainingTime} from '../common/RemainingTime'; @@ -54,7 +53,6 @@ const Clients = observer(() => { Name - Token Elevation ends Expires in Last Used @@ -69,7 +67,6 @@ const Clients = observer(() => { { interface IRowProps { name: string; - value: string; createdAt: string; lastUsed: string | null; elevatedUntil?: string; @@ -135,7 +131,6 @@ interface IRowProps { const Row = ({ name, - value, createdAt, lastUsed, elevatedUntil, @@ -146,12 +141,6 @@ const Row = ({ }: IRowProps) => ( {name} - - - { + try { + await navigator.clipboard.writeText(value); + } catch (error) { + console.error('Failed to copy to clipboard:', error); + try { + const elem = document.createElement('textarea'); + elem.value = value; + document.body.appendChild(elem); + elem.select(); + document.execCommand('copy'); + document.body.removeChild(elem); + } catch (error) { + console.error('Failed to copy to clipboard (fallback):', error); + } + } +}; diff --git a/ui/src/common/CopyableSecret.tsx b/ui/src/common/CopyableSecret.tsx index 0c1dfbddf..6acb9bffa 100644 --- a/ui/src/common/CopyableSecret.tsx +++ b/ui/src/common/CopyableSecret.tsx @@ -5,6 +5,7 @@ import Copy from '@mui/icons-material/FileCopyOutlined'; import VisibilityOff from '@mui/icons-material/VisibilityOff'; import React, {CSSProperties} from 'react'; import {useStores} from '../stores'; +import {copyToClipboard} from '../clipboard'; interface IProps { value: string; @@ -16,18 +17,14 @@ const CopyableSecret = ({value, style}: IProps) => { const text = visible ? value : '•••••••••••••••'; const {snackManager} = useStores(); const toggleVisibility = () => setVisible((b) => !b); - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(value); - snackManager.snack('Copied to clipboard'); - } catch (error) { - console.error('Failed to copy to clipboard:', error); - snackManager.snack('Failed to copy to clipboard'); - } - }; return (
- + + copyToClipboard(value).finally(() => snackManager.snack('Copied to clipboard')) + } + title="Copy to clipboard" + size="large"> From a40265bbbfeacbe43c44e5aa09952184687a466c Mon Sep 17 00:00:00 2001 From: Yumechi Date: Sun, 21 Jun 2026 10:54:09 +0800 Subject: [PATCH 3/7] fix: e2e tests --- ui/src/application/AddApplicationDialog.tsx | 26 ++++++++++++++------ ui/src/client/AddClientDialog.tsx | 26 ++++++++++++++------ ui/src/tests/application.test.ts | 27 +++++++++------------ ui/src/tests/client.test.ts | 25 ++++++++++++------- ui/src/tests/selector.ts | 1 + ui/src/tests/setup.ts | 1 + ui/src/tests/utils.ts | 15 ++++++------ 7 files changed, 72 insertions(+), 49 deletions(-) diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index e1485ed11..786223702 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -33,12 +33,22 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { Create an application {returnToken ? ( - - Your token is: - - {returnToken} - - + <> + Your token will only be shown once. + + { + window.getSelection()?.selectAllChildren(e.currentTarget); + }}> + + {returnToken} + + + ) : ( <> @@ -81,8 +91,8 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { )} {returnToken ? ( - ) : ( diff --git a/ui/src/client/AddClientDialog.tsx b/ui/src/client/AddClientDialog.tsx index 7a5c066d3..faa812b90 100644 --- a/ui/src/client/AddClientDialog.tsx +++ b/ui/src/client/AddClientDialog.tsx @@ -31,12 +31,22 @@ const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { Create a client {returnToken ? ( - - Your token is: - - {returnToken} - - + <> + Your token will only be shown once. + + { + window.getSelection()?.selectAllChildren(e.currentTarget); + }}> + + {returnToken} + + + ) : ( <> { )} {returnToken ? ( - ) : ( await gotify.close()); enum Col { Name = 3, - Token = 4, - Description = 5, - DefaultPriority = 6, - LastUsed = 7, - Created = 8, - EditUpdate = 9, - EditDelete = 10, + Description = 4, + DefaultPriority = 5, + LastUsed = 6, + Created = 7, + EditUpdate = 8, + EditDelete = 9, } -const hiddenToken = '•••••••••••••••'; - const $table = selector.table('#app-table'); const $dialog = selector.form('#app-dialog'); @@ -34,7 +31,6 @@ const waitforApp = (name: string, description: string, row: number): (() => Promise) => async () => { await waitForExists(page, $table.cell(row, Col.Name), name); - expect(await innerText(page, $table.cell(row, Col.Token))).toBe(hiddenToken); expect(await innerText(page, $table.cell(row, Col.Description))).toBe(description); }; @@ -65,6 +61,11 @@ const createApp = await page.type($dialog.input('.name'), name); await page.type($dialog.textarea('.description'), description); await page.click($dialog.button('.create')); + await page.waitForSelector($dialog.button('.finish')); + await page.waitForSelector($dialog.p('.token')); + const token = await innerText(page, $dialog.p('.token')); + expect(token.startsWith('gtfy_app.')).toBeTruthy(); + await page.click($dialog.button('.finish')); await waitToDisappear(page, $dialog.selector()); }; @@ -93,12 +94,6 @@ describe('Application', () => { it('has server app', waitforApp('server', '#1', 1)); it('has desktop app', waitforApp('desktop', '#2', 2)); it('has raspberry app', waitforApp('raspberry', '#3', 3)); - it('shows token', async () => { - await page.click($table.cell(3, Col.Token, '.toggle-visibility')); - const token = await innerText(page, $table.cell(3, Col.Token)); - expect(token.startsWith('A')).toBeTruthy(); - await page.click($table.cell(3, Col.Token, '.toggle-visibility')); - }); }); it('updates application', async () => { await updateApp(1, {name: 'server_linux'})(); diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts index 0dea6a29d..e15dd5754 100644 --- a/ui/src/tests/client.test.ts +++ b/ui/src/tests/client.test.ts @@ -27,7 +27,12 @@ interface ClientFields { } const fillClientDialog = - (opener: string, submit: string, data: ClientFields): (() => Promise) => + ( + opener: string, + submit: string, + data: ClientFields, + hasToken: boolean + ): (() => Promise) => async () => { await page.click(opener); await page.waitForSelector($dialog.selector()); @@ -42,13 +47,21 @@ const fillClientDialog = await page.type(expiresSelector, data.expiresAfter.toString()); } await page.click($dialog.button(submit)); + if (hasToken) { + await page.waitForSelector($dialog.p('.token')); + const token = await innerText(page, $dialog.p('.token')); + expect(token.startsWith('gtfy_client.')).toBeTruthy(); + await page.waitForSelector($dialog.button('.finish')); + await page.click($dialog.button('.finish')); + } await waitToDisappear(page, $dialog.selector()); }; -const createClient = (data: ClientFields) => fillClientDialog('#create-client', '.create', data); +const createClient = (data: ClientFields) => + fillClientDialog('#create-client', '.create', data, true); const updateClient = (id: number, data: ClientFields) => - fillClientDialog($table.cell(id, ClientCol.Edit, '.edit'), '.update', data); + fillClientDialog($table.cell(id, ClientCol.Edit, '.edit'), '.update', data, false); const $table = selector.table('#client-table'); const $dialog = selector.form('#client-dialog'); @@ -88,12 +101,6 @@ describe('Client', () => { it('has updated expires after', async () => { expect(await innerText(page, $table.cell(1, ClientCol.ExpiresIn))).toBe('9h 59m'); }); - it('shows token', async () => { - await page.click($table.cell(3, ClientCol.Token, '.toggle-visibility')); - expect( - (await innerText(page, $table.cell(3, ClientCol.Token))).startsWith('C') - ).toBeTruthy(); - }); it('shows last seen', async () => { expect(await innerText(page, $table.cell(3, ClientCol.LastSeen))).toBeTruthy(); }); diff --git a/ui/src/tests/selector.ts b/ui/src/tests/selector.ts index 2bcbda784..ca363c6a2 100644 --- a/ui/src/tests/selector.ts +++ b/ui/src/tests/selector.ts @@ -13,6 +13,7 @@ export const form = (dialogSelector: string) => ({ input: (selector: string) => `${dialogSelector} ${selector} input`, textarea: (selector: string) => `${dialogSelector} ${selector} textarea`, button: (selector: string) => `${dialogSelector} button${selector}`, + p: (selector: string) => `${dialogSelector} p${selector}`, }); export const $confirmDialog = form('.confirm-dialog'); diff --git a/ui/src/tests/setup.ts b/ui/src/tests/setup.ts index 84b757afd..b4caf62eb 100644 --- a/ui/src/tests/setup.ts +++ b/ui/src/tests/setup.ts @@ -41,6 +41,7 @@ export const newTest = async (pluginsDir = ''): Promise => { const browser = await puppeteer.launch({ headless: process.env.CI === 'true', args: [`--window-size=1920,1080`, '--no-sandbox'], + executablePath: '/usr/bin/chromium', }); const page = await browser.newPage(); await page.setViewport({width: 1920, height: 1080}); diff --git a/ui/src/tests/utils.ts b/ui/src/tests/utils.ts index d11ebc1b4..d01dd0f59 100644 --- a/ui/src/tests/utils.ts +++ b/ui/src/tests/utils.ts @@ -70,12 +70,11 @@ export const clearField = async (element: ElementHandle | Page, selector: string export enum ClientCol { Name = 1, - Token = 2, - ElevationEnds = 3, - ExpiresIn = 4, - LastSeen = 5, - Created = 6, - Elevate = 7, - Edit = 8, - Delete = 9, + ElevationEnds = 2, + ExpiresIn = 3, + LastSeen = 4, + Created = 5, + Elevate = 6, + Edit = 7, + Delete = 8, } From 0d294f9c4fe5077942a3fb252feab348e7e28236 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Sun, 21 Jun 2026 11:06:06 +0800 Subject: [PATCH 4/7] fixup! fix: e2e tests --- ui/src/tests/elevation.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/tests/elevation.test.ts b/ui/src/tests/elevation.test.ts index 2c58e06a8..993708f20 100644 --- a/ui/src/tests/elevation.test.ts +++ b/ui/src/tests/elevation.test.ts @@ -53,6 +53,8 @@ describe('Elevation', () => { await page.waitForSelector($clientDialog.selector()); await page.type($clientDialog.input('.name'), 'test-client'); await page.click($clientDialog.button('.create')); + await page.waitForSelector($clientDialog.button('.finish')); + await page.click($clientDialog.button('.finish')); await waitToDisappear(page, $clientDialog.selector()); await page.waitForSelector($clientTable.row(2)); expect(await count(page, $clientTable.rows())).toBe(2); From 218929bacd7e4227410c2b954f5dd9eecf3802a3 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Sun, 21 Jun 2026 11:51:22 +0800 Subject: [PATCH 5/7] doc: document tokens are now optional fields for app and aclient --- docs/spec.json | 434 +++++++++++++++++++++---------------------- model/application.go | 1 - model/client.go | 1 - 3 files changed, 216 insertions(+), 220 deletions(-) diff --git a/docs/spec.json b/docs/spec.json index 04be96460..07a11e4be 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -23,20 +23,6 @@ "paths": { "/application": { "get": { - "security": [ - { - "clientTokenAuthorizationHeader": [] - }, - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ], "consumes": [ "application/json" ], @@ -70,9 +56,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -86,7 +70,9 @@ { "basicAuth": [] } - ], + ] + }, + "post": { "consumes": [ "application/json" ], @@ -134,11 +120,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/application/{id}": { - "put": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -152,7 +134,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/application/{id}": { + "put": { "consumes": [ "application/json" ], @@ -214,9 +200,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "delete": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -230,7 +214,9 @@ { "basicAuth": [] } - ], + ] + }, + "delete": { "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -281,11 +267,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/application/{id}/image": { - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -299,7 +281,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/application/{id}/image": { + "post": { "consumes": [ "multipart/form-data" ], @@ -365,9 +351,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "delete": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -381,7 +365,9 @@ { "basicAuth": [] } - ], + ] + }, + "delete": { "consumes": [ "application/json" ], @@ -437,11 +423,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/application/{id}/message": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -455,7 +437,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/application/{id}/message": { + "get": { "produces": [ "application/json" ], @@ -522,9 +508,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "delete": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -538,7 +522,9 @@ { "basicAuth": [] } - ], + ] + }, + "delete": { "produces": [ "application/json" ], @@ -585,16 +571,25 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/auth/local/login": { - "post": { + }, "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, { "basicAuth": [] } - ], + ] + } + }, + "/auth/local/login": { + "post": { "consumes": [ "application/x-www-form-urlencoded" ], @@ -634,22 +629,16 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/auth/logout": { - "post": { + }, "security": [ - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, { "basicAuth": [] } - ], + ] + } + }, + "/auth/logout": { + "post": { "description": "Clears the session cookie and deletes the associated client.", "produces": [ "application/json" @@ -675,7 +664,18 @@ "$ref": "#/definitions/Error" } } - } + }, + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ] } }, "/auth/oidc/callback": { @@ -868,20 +868,6 @@ }, "/client": { "get": { - "security": [ - { - "clientTokenAuthorizationHeader": [] - }, - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ], "consumes": [ "application/json" ], @@ -915,9 +901,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -931,7 +915,9 @@ { "basicAuth": [] } - ], + ] + }, + "post": { "consumes": [ "application/json" ], @@ -979,11 +965,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/client/{id}": { - "put": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -997,7 +979,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/client/{id}": { + "put": { "consumes": [ "application/json" ], @@ -1059,9 +1045,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "delete": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1075,7 +1059,9 @@ { "basicAuth": [] } - ], + ] + }, + "delete": { "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -1126,11 +1112,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/client/{id}/elevate": { - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1144,7 +1126,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/client/{id}/elevate": { + "post": { "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -1198,11 +1184,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/current/user": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1216,7 +1198,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/current/user": { + "get": { "description": "Requires elevated authentication.", "produces": [ "application/json" @@ -1245,11 +1231,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/current/user/password": { - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1263,7 +1245,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/current/user/password": { + "post": { "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -1309,7 +1295,21 @@ "$ref": "#/definitions/Error" } } - } + }, + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ] } }, "/gotifyinfo": { @@ -1360,20 +1360,6 @@ }, "/message": { "get": { - "security": [ - { - "clientTokenAuthorizationHeader": [] - }, - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ], "produces": [ "application/json" ], @@ -1426,19 +1412,8 @@ "$ref": "#/definitions/Error" } } - } - }, - "post": { + }, "security": [ - { - "appTokenAuthorizationHeader": [] - }, - { - "appTokenHeader": [] - }, - { - "appTokenQuery": [] - }, { "clientTokenAuthorizationHeader": [] }, @@ -1451,7 +1426,9 @@ { "basicAuth": [] } - ], + ] + }, + "post": { "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" @@ -1500,10 +1477,17 @@ "$ref": "#/definitions/Error" } } - } - }, - "delete": { + }, "security": [ + { + "appTokenAuthorizationHeader": [] + }, + { + "appTokenHeader": [] + }, + { + "appTokenQuery": [] + }, { "clientTokenAuthorizationHeader": [] }, @@ -1516,7 +1500,9 @@ { "basicAuth": [] } - ], + ] + }, + "delete": { "produces": [ "application/json" ], @@ -1541,11 +1527,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/message/{id}": { - "delete": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1559,7 +1541,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/message/{id}": { + "delete": { "produces": [ "application/json" ], @@ -1606,11 +1592,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/plugin": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1624,7 +1606,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/plugin": { + "get": { "consumes": [ "application/json" ], @@ -1670,11 +1656,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/plugin/{id}/config": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1688,7 +1670,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/plugin/{id}/config": { + "get": { "consumes": [ "application/json" ], @@ -1748,9 +1734,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1764,7 +1748,9 @@ { "basicAuth": [] } - ], + ] + }, + "post": { "consumes": [ "application/x-yaml" ], @@ -1820,11 +1806,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/plugin/{id}/disable": { - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1838,7 +1820,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/plugin/{id}/disable": { + "post": { "consumes": [ "application/json" ], @@ -1888,11 +1874,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/plugin/{id}/display": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1906,7 +1888,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/plugin/{id}/display": { + "get": { "consumes": [ "application/json" ], @@ -1959,11 +1945,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/plugin/{id}/enable": { - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -1977,7 +1959,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/plugin/{id}/enable": { + "post": { "consumes": [ "application/json" ], @@ -2027,11 +2013,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/stream": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -2045,7 +2027,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/stream": { + "get": { "produces": [ "application/json" ], @@ -2085,11 +2071,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/user": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -2103,7 +2085,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/user": { + "get": { "description": "Requires elevated authentication.", "produces": [ "application/json" @@ -2135,9 +2121,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -2151,7 +2135,9 @@ { "basicAuth": [] } - ], + ] + }, + "post": { "description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.\n\nRequires elevated authentication.", "consumes": [ "application/json" @@ -2200,11 +2186,7 @@ "$ref": "#/definitions/Error" } } - } - } - }, - "/user/{id}": { - "get": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -2218,7 +2200,11 @@ { "basicAuth": [] } - ], + ] + } + }, + "/user/{id}": { + "get": { "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -2272,9 +2258,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "post": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -2288,7 +2272,9 @@ { "basicAuth": [] } - ], + ] + }, + "post": { "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -2351,9 +2337,7 @@ "$ref": "#/definitions/Error" } } - } - }, - "delete": { + }, "security": [ { "clientTokenAuthorizationHeader": [] @@ -2367,7 +2351,9 @@ { "basicAuth": [] } - ], + ] + }, + "delete": { "description": "Requires elevated authentication.", "produces": [ "application/json" @@ -2415,7 +2401,21 @@ "$ref": "#/definitions/Error" } } - } + }, + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ] } }, "/version": { @@ -2446,7 +2446,6 @@ "title": "Application Model", "required": [ "id", - "token", "name", "description", "internal", @@ -2570,7 +2569,6 @@ "title": "Client Model", "required": [ "id", - "token", "name", "createdAt" ], diff --git a/model/application.go b/model/application.go index 9a8fbb2eb..69cd9cad3 100644 --- a/model/application.go +++ b/model/application.go @@ -17,7 +17,6 @@ type Application struct { // The application token. Can be used as `appToken`. See Authentication. // // read only: true - // required: true // example: AWH0wZ5r0Mbac.r Token string `gorm:"type:varchar(180);uniqueIndex:uix_applications_token" json:"token"` UserID uint `gorm:"index;uniqueIndex:uix_application_user_id_sort_key,priority:1" json:"-"` diff --git a/model/client.go b/model/client.go index c4d418b19..81ec9f6cb 100644 --- a/model/client.go +++ b/model/client.go @@ -17,7 +17,6 @@ type Client struct { // The client token. Can be used as `clientToken`. See Authentication. // // read only: true - // required: true // example: CWH0wZ5r0Mbac.r Token string `gorm:"type:varchar(180);uniqueIndex:uix_clients_token" json:"token"` UserID uint `gorm:"index" json:"-"` From 108e7246f149cc25156111a8fdf90f1d0620cdc5 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Sun, 21 Jun 2026 12:02:48 +0800 Subject: [PATCH 6/7] fixup(doc): swagger version again --- docs/spec.json | 432 ++++++++++++++++++++++++------------------------- 1 file changed, 216 insertions(+), 216 deletions(-) diff --git a/docs/spec.json b/docs/spec.json index 07a11e4be..498233b71 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -23,6 +23,20 @@ "paths": { "/application": { "get": { + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], "consumes": [ "application/json" ], @@ -56,7 +70,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -70,9 +86,7 @@ { "basicAuth": [] } - ] - }, - "post": { + ], "consumes": [ "application/json" ], @@ -120,7 +134,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/application/{id}": { + "put": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -134,11 +152,7 @@ { "basicAuth": [] } - ] - } - }, - "/application/{id}": { - "put": { + ], "consumes": [ "application/json" ], @@ -200,7 +214,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "delete": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -214,9 +230,7 @@ { "basicAuth": [] } - ] - }, - "delete": { + ], "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -267,7 +281,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/application/{id}/image": { + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -281,11 +299,7 @@ { "basicAuth": [] } - ] - } - }, - "/application/{id}/image": { - "post": { + ], "consumes": [ "multipart/form-data" ], @@ -351,7 +365,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "delete": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -365,9 +381,7 @@ { "basicAuth": [] } - ] - }, - "delete": { + ], "consumes": [ "application/json" ], @@ -423,7 +437,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/application/{id}/message": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -437,11 +455,7 @@ { "basicAuth": [] } - ] - } - }, - "/application/{id}/message": { - "get": { + ], "produces": [ "application/json" ], @@ -508,7 +522,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "delete": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -522,9 +538,7 @@ { "basicAuth": [] } - ] - }, - "delete": { + ], "produces": [ "application/json" ], @@ -571,25 +585,16 @@ "$ref": "#/definitions/Error" } } - }, - "security": [ - { - "clientTokenAuthorizationHeader": [] - }, - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ] + } } }, "/auth/local/login": { "post": { + "security": [ + { + "basicAuth": [] + } + ], "consumes": [ "application/x-www-form-urlencoded" ], @@ -629,16 +634,22 @@ "$ref": "#/definitions/Error" } } - }, - "security": [ - { - "basicAuth": [] - } - ] + } } }, "/auth/logout": { "post": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], "description": "Clears the session cookie and deletes the associated client.", "produces": [ "application/json" @@ -664,18 +675,7 @@ "$ref": "#/definitions/Error" } } - }, - "security": [ - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ] + } } }, "/auth/oidc/callback": { @@ -868,6 +868,20 @@ }, "/client": { "get": { + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], "consumes": [ "application/json" ], @@ -901,7 +915,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -915,9 +931,7 @@ { "basicAuth": [] } - ] - }, - "post": { + ], "consumes": [ "application/json" ], @@ -965,7 +979,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/client/{id}": { + "put": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -979,11 +997,7 @@ { "basicAuth": [] } - ] - } - }, - "/client/{id}": { - "put": { + ], "consumes": [ "application/json" ], @@ -1045,7 +1059,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "delete": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1059,9 +1075,7 @@ { "basicAuth": [] } - ] - }, - "delete": { + ], "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -1112,7 +1126,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/client/{id}/elevate": { + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1126,11 +1144,7 @@ { "basicAuth": [] } - ] - } - }, - "/client/{id}/elevate": { - "post": { + ], "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -1184,7 +1198,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/current/user": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1198,11 +1216,7 @@ { "basicAuth": [] } - ] - } - }, - "/current/user": { - "get": { + ], "description": "Requires elevated authentication.", "produces": [ "application/json" @@ -1231,7 +1245,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/current/user/password": { + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1245,11 +1263,7 @@ { "basicAuth": [] } - ] - } - }, - "/current/user/password": { - "post": { + ], "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -1295,21 +1309,7 @@ "$ref": "#/definitions/Error" } } - }, - "security": [ - { - "clientTokenAuthorizationHeader": [] - }, - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ] + } } }, "/gotifyinfo": { @@ -1360,6 +1360,20 @@ }, "/message": { "get": { + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], "produces": [ "application/json" ], @@ -1412,8 +1426,19 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "post": { "security": [ + { + "appTokenAuthorizationHeader": [] + }, + { + "appTokenHeader": [] + }, + { + "appTokenQuery": [] + }, { "clientTokenAuthorizationHeader": [] }, @@ -1426,9 +1451,7 @@ { "basicAuth": [] } - ] - }, - "post": { + ], "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" @@ -1477,17 +1500,10 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "delete": { "security": [ - { - "appTokenAuthorizationHeader": [] - }, - { - "appTokenHeader": [] - }, - { - "appTokenQuery": [] - }, { "clientTokenAuthorizationHeader": [] }, @@ -1500,9 +1516,7 @@ { "basicAuth": [] } - ] - }, - "delete": { + ], "produces": [ "application/json" ], @@ -1527,7 +1541,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/message/{id}": { + "delete": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1541,11 +1559,7 @@ { "basicAuth": [] } - ] - } - }, - "/message/{id}": { - "delete": { + ], "produces": [ "application/json" ], @@ -1592,7 +1606,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/plugin": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1606,11 +1624,7 @@ { "basicAuth": [] } - ] - } - }, - "/plugin": { - "get": { + ], "consumes": [ "application/json" ], @@ -1656,7 +1670,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/plugin/{id}/config": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1670,11 +1688,7 @@ { "basicAuth": [] } - ] - } - }, - "/plugin/{id}/config": { - "get": { + ], "consumes": [ "application/json" ], @@ -1734,7 +1748,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1748,9 +1764,7 @@ { "basicAuth": [] } - ] - }, - "post": { + ], "consumes": [ "application/x-yaml" ], @@ -1806,7 +1820,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/plugin/{id}/disable": { + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1820,11 +1838,7 @@ { "basicAuth": [] } - ] - } - }, - "/plugin/{id}/disable": { - "post": { + ], "consumes": [ "application/json" ], @@ -1874,7 +1888,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/plugin/{id}/display": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1888,11 +1906,7 @@ { "basicAuth": [] } - ] - } - }, - "/plugin/{id}/display": { - "get": { + ], "consumes": [ "application/json" ], @@ -1945,7 +1959,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/plugin/{id}/enable": { + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -1959,11 +1977,7 @@ { "basicAuth": [] } - ] - } - }, - "/plugin/{id}/enable": { - "post": { + ], "consumes": [ "application/json" ], @@ -2013,7 +2027,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/stream": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -2027,11 +2045,7 @@ { "basicAuth": [] } - ] - } - }, - "/stream": { - "get": { + ], "produces": [ "application/json" ], @@ -2071,7 +2085,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/user": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -2085,11 +2103,7 @@ { "basicAuth": [] } - ] - } - }, - "/user": { - "get": { + ], "description": "Requires elevated authentication.", "produces": [ "application/json" @@ -2121,7 +2135,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -2135,9 +2151,7 @@ { "basicAuth": [] } - ] - }, - "post": { + ], "description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.\n\nRequires elevated authentication.", "consumes": [ "application/json" @@ -2186,7 +2200,11 @@ "$ref": "#/definitions/Error" } } - }, + } + } + }, + "/user/{id}": { + "get": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -2200,11 +2218,7 @@ { "basicAuth": [] } - ] - } - }, - "/user/{id}": { - "get": { + ], "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -2258,7 +2272,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "post": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -2272,9 +2288,7 @@ { "basicAuth": [] } - ] - }, - "post": { + ], "description": "Requires elevated authentication.", "consumes": [ "application/json" @@ -2337,7 +2351,9 @@ "$ref": "#/definitions/Error" } } - }, + } + }, + "delete": { "security": [ { "clientTokenAuthorizationHeader": [] @@ -2351,9 +2367,7 @@ { "basicAuth": [] } - ] - }, - "delete": { + ], "description": "Requires elevated authentication.", "produces": [ "application/json" @@ -2401,21 +2415,7 @@ "$ref": "#/definitions/Error" } } - }, - "security": [ - { - "clientTokenAuthorizationHeader": [] - }, - { - "clientTokenHeader": [] - }, - { - "clientTokenQuery": [] - }, - { - "basicAuth": [] - } - ] + } } }, "/version": { From 76fb8edbaa85debbdb1307d9987da980dd90ac00 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Sun, 21 Jun 2026 12:20:10 +0800 Subject: [PATCH 7/7] address review comments --- auth/authentication.go | 4 ++-- auth/token_test.go | 2 -- router/router_test.go | 2 -- ui/src/application/AddApplicationDialog.tsx | 12 +++++++++++- ui/src/client/AddClientDialog.tsx | 13 +++++++++++-- ui/src/clipboard.ts | 18 +++++++----------- ui/src/common/CopyableSecret.tsx | 5 ++++- ui/src/tests/setup.ts | 1 - 8 files changed, 35 insertions(+), 22 deletions(-) diff --git a/auth/authentication.go b/auth/authentication.go index 5da712d1f..466dc2699 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -155,7 +155,7 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu if strings.HasPrefix(token, enhancedTokenPrefix) { complexToken, err := ParseEnhancedToken(token) if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) { - return authStateSkip, err + return authStateSkip, nil } token = complexToken.PublicForm() } @@ -197,7 +197,7 @@ func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { if strings.HasPrefix(token, enhancedTokenPrefix) { complexToken, err := ParseEnhancedToken(token) if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) { - return authStateSkip, err + return authStateSkip, nil } token = complexToken.PublicForm() } diff --git a/auth/token_test.go b/auth/token_test.go index c88ce168d..c0167e461 100644 --- a/auth/token_test.go +++ b/auth/token_test.go @@ -3,7 +3,6 @@ package auth import ( "crypto/rand" "errors" - "log" "testing" "testing/iotest" @@ -12,7 +11,6 @@ import ( func TestNewComplexToken(t *testing.T) { token := NewEnhancedToken("A12") - log.Printf("token: %s", token.String()) canonicalizedExpected := token.PublicForm() tokenParsed, err := ParseEnhancedToken(token.String()) assert.NoError(t, err) diff --git a/router/router_test.go b/router/router_test.go index 5cd0ba620..f72e95a8f 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "log" "net/http" "net/http/httptest" "strings" @@ -322,7 +321,6 @@ func (s *IntegrationSuite) TestSendMessage() { token := &model.Application{} json.NewDecoder(res.Body).Decode(token) assert.Equal(s.T(), "backup-server", token.Name) - log.Printf("token: %s", token.Token) req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup done"}`) req.Header.Add("X-Gotify-Key", token.Token) diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index 786223702..6317a043f 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -10,6 +10,7 @@ import {NumberField} from '../common/NumberField'; import React, {useState} from 'react'; import {Typography} from '@mui/material'; import {copyToClipboard} from '../clipboard'; +import {useStores} from '../stores'; interface IProps { fClose: VoidFunction; @@ -21,6 +22,7 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [defaultPriority, setDefaultPriority] = useState(0); + const {snackManager} = useStores(); const submitEnabled = name.length !== 0; const submitAndNext = async () => { @@ -86,7 +88,15 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { {returnToken ? ( - + ) : ( )} diff --git a/ui/src/client/AddClientDialog.tsx b/ui/src/client/AddClientDialog.tsx index faa812b90..041bc96dd 100644 --- a/ui/src/client/AddClientDialog.tsx +++ b/ui/src/client/AddClientDialog.tsx @@ -9,6 +9,7 @@ import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; import {DialogContentText, Typography} from '@mui/material'; import {copyToClipboard} from '../clipboard'; +import {useStores} from '../stores'; interface IProps { fClose: VoidFunction; @@ -19,7 +20,7 @@ const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { const [returnToken, setReturnToken] = useState(''); const [name, setName] = useState(''); const [expiresAfter, setExpiresAfter] = useState(0); - + const {snackManager} = useStores(); const submitEnabled = name.length !== 0; const submitAndNext = async () => { const token = await fOnSubmit(name, Math.max(0, expiresAfter)); @@ -72,7 +73,15 @@ const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { {returnToken ? ( - + ) : ( )} diff --git a/ui/src/clipboard.ts b/ui/src/clipboard.ts index 8ce0a2226..07206e948 100644 --- a/ui/src/clipboard.ts +++ b/ui/src/clipboard.ts @@ -2,16 +2,12 @@ export const copyToClipboard = async (value: string) => { try { await navigator.clipboard.writeText(value); } catch (error) { - console.error('Failed to copy to clipboard:', error); - try { - const elem = document.createElement('textarea'); - elem.value = value; - document.body.appendChild(elem); - elem.select(); - document.execCommand('copy'); - document.body.removeChild(elem); - } catch (error) { - console.error('Failed to copy to clipboard (fallback):', error); - } + console.warn('Failed to copy to clipboard using Clipboard API:', error); + const elem = document.createElement('textarea'); + elem.value = value; + document.body.appendChild(elem); + elem.select(); + document.execCommand('copy'); + document.body.removeChild(elem); } }; diff --git a/ui/src/common/CopyableSecret.tsx b/ui/src/common/CopyableSecret.tsx index 6acb9bffa..9176e0dbc 100644 --- a/ui/src/common/CopyableSecret.tsx +++ b/ui/src/common/CopyableSecret.tsx @@ -21,7 +21,10 @@ const CopyableSecret = ({value, style}: IProps) => {
- copyToClipboard(value).finally(() => snackManager.snack('Copied to clipboard')) + copyToClipboard(value).then( + () => snackManager.snack('Copied to clipboard'), + () => snackManager.snack('Cannot access clipboard.') + ) } title="Copy to clipboard" size="large"> diff --git a/ui/src/tests/setup.ts b/ui/src/tests/setup.ts index b43f41edf..005d0e999 100644 --- a/ui/src/tests/setup.ts +++ b/ui/src/tests/setup.ts @@ -73,7 +73,6 @@ export const newTest = async ( const browser = await puppeteer.launch({ headless: process.env.CI === 'true', args: [`--window-size=1920,1080`, '--no-sandbox'], - executablePath: '/usr/bin/chromium', }); const page = await browser.newPage(); await page.setViewport({width: 1920, height: 1080});