diff --git a/api/application_test.go b/api/application_test.go index 4070d0bf..c0263c68 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -78,6 +78,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { Name: "custom_name", Description: "description_text", SortKey: "a5", + CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { @@ -96,8 +97,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() Internal: true, LastUsed: nil, SortKey: "a1", + CreatedAt: testdb.Now, } - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortKey":"a1"}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "createdAt":"2020-01-01T00:00:00Z", "lastUsed":null, "sortKey":"a1"}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { @@ -129,19 +131,19 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar s.a.CreateApplication(s.ctx) - expectedJSONValue, _ := json.Marshal(&model.Application{ + expected := &model.Application{ ID: 1, Token: firstApplicationToken, - UserID: 5, Name: "name", Description: "description", Internal: false, Image: "static/defaultapp.png", SortKey: "a5", - }) + CreatedAt: testdb.Now, + } assert.Equal(s.T(), 200, s.recorder.Code) - assert.Equal(s.T(), string(expectedJSONValue), s.recorder.Body.String()) + test.BodyEquals(s.T(), expected, s.recorder) } func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { @@ -165,7 +167,7 @@ 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"} + expected := &model.Application{ID: 1, Token: firstApplicationToken, 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) @@ -181,12 +183,12 @@ func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() { s.a.CreateApplication(s.ctx) expected := &model.Application{ - ID: 1, - Token: firstApplicationToken, - Name: "custom_name", - Image: "static/defaultapp.png", - UserID: 5, - SortKey: "a0", + 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) @@ -201,7 +203,7 @@ func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() { s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"} + expected := &model.Application{ID: 2, Token: secondApplicationToken, 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) @@ -530,6 +532,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSucces Name: "new_name", Description: "new_description_text", SortKey: "a0", + CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) @@ -553,6 +556,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() { Name: "new_name", Description: "", SortKey: "a0", + CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) @@ -577,6 +581,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() Description: "", DefaultPriority: 4, SortKey: "a0", + CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) diff --git a/api/client.go b/api/client.go index 7bb06bf7..58a3916b 100644 --- a/api/client.go +++ b/api/client.go @@ -39,6 +39,11 @@ type ClientParams struct { // required: true // example: My Client Name string `form:"name" query:"name" json:"name" binding:"required"` + // The number of seconds of inactivity after which the client is removed. + // 0 (or omitted) means the client never expires. + // + // example: 2592000 + ExpiresAfterInactivitySeconds *uint `form:"expiresAfterInactivitySeconds" query:"expiresAfterInactivitySeconds" json:"expiresAfterInactivitySeconds"` } // UpdateClient updates a client by its id. @@ -94,10 +99,14 @@ func (a *ClientAPI) UpdateClient(ctx *gin.Context) { newValues := ClientParams{} if err := ctx.Bind(&newValues); err == nil { client.Name = newValues.Name + if newValues.ExpiresAfterInactivitySeconds != nil { + client.ExpiresAfterInactivitySeconds = *newValues.ExpiresAfterInactivitySeconds + } if success := successOrAbort(ctx, 500, a.DB.UpdateClient(client)); !success { return } + client.PopulateExpiresAt() ctx.JSON(200, client) } } else { @@ -147,10 +156,14 @@ func (a *ClientAPI) CreateClient(ctx *gin.Context) { Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), UserID: auth.GetUserID(ctx), } + if clientParams.ExpiresAfterInactivitySeconds != nil { + client.ExpiresAfterInactivitySeconds = *clientParams.ExpiresAfterInactivitySeconds + } if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { return } + client.PopulateExpiresAt() ctx.JSON(200, client) } } @@ -190,6 +203,7 @@ func (a *ClientAPI) GetClients(ctx *gin.Context) { if client.ElevatedUntil != nil && !now.Before(*client.ElevatedUntil) { client.ElevatedUntil = nil } + client.PopulateExpiresAt() } ctx.JSON(200, clients) } diff --git a/api/client_test.go b/api/client_test.go index 0c1a6d6f..40422e14 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -59,8 +59,8 @@ func (s *ClientSuite) AfterTest(suiteName, testName string) { } func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() { - actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"} - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","lastUsed":null}`) + actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient", CreatedAt: testdb.Now} + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","createdAt":"2020-01-01T00:00:00Z","lastUsed":null,"expiresAfterInactivitySeconds":0}`) } func (s *ClientSuite) Test_CreateClient_mapAllParameters() { @@ -71,7 +71,7 @@ func (s *ClientSuite) Test_CreateClient_mapAllParameters() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name"} + expected := &model.Client{ID: 1, Token: firstClientToken, 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) @@ -85,7 +85,7 @@ 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"} + expected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, 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) { @@ -129,7 +129,7 @@ func (s *ClientSuite) Test_CreateClient_returnsClientWithID() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", UserID: 5} + expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } @@ -142,7 +142,7 @@ func (s *ClientSuite) Test_CreateClient_withExistingToken() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", UserID: 5} + expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } @@ -189,6 +189,34 @@ func (s *ClientSuite) Test_DeleteClient() { assert.True(s.T(), s.notified) } +func (s *ClientSuite) Test_CreateClient_acceptsExpiresAfterInactivitySeconds() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=custom_name&expiresAfterInactivitySeconds=3600") + + s.a.CreateClient(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), uint(3600), client.ExpiresAfterInactivitySeconds) + } +} + +func (s *ClientSuite) Test_UpdateClient_updatesExpiresAfterInactivitySeconds() { + s.db.User(5).NewClientWithToken(1, firstClientToken) + + test.WithUser(s.ctx, 5) + s.withFormData("name=firefox&expiresAfterInactivitySeconds=7200") + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.UpdateClient(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), uint(7200), client.ExpiresAfterInactivitySeconds) + } +} + func (s *ClientSuite) Test_UpdateClient_expectSuccess() { s.db.User(5).NewClientWithToken(1, firstClientToken) @@ -198,10 +226,11 @@ func (s *ClientSuite) Test_UpdateClient_expectSuccess() { s.a.UpdateClient(s.ctx) expected := &model.Client{ - ID: 1, - Token: firstClientToken, - UserID: 5, - Name: "firefox", + ID: 1, + Token: firstClientToken, + UserID: 5, + Name: "firefox", + CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) diff --git a/api/oidc.go b/api/oidc.go index 6bec28b4..b61e23c6 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -424,10 +424,11 @@ 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) client := &model.Client{ - Name: name, - Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), - UserID: userID, - ElevatedUntil: &elevatedUntil, + Name: name, + Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), + UserID: userID, + ElevatedUntil: &elevatedUntil, + ExpiresAfterInactivitySeconds: auth.CookieMaxAge, } return client, a.DB.CreateClient(client) } diff --git a/api/oidc_test.go b/api/oidc_test.go index 0edf0c78..473e57f6 100644 --- a/api/oidc_test.go +++ b/api/oidc_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/decaymap" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/test" @@ -153,6 +154,7 @@ func (s *OIDCSuite) Test_CreateClient() { assert.Equal(s.T(), "MyPhone", client.Name) assert.Equal(s.T(), "Ctesttoken00001", client.Token) assert.Equal(s.T(), uint(1), client.UserID) + assert.Equal(s.T(), uint(auth.CookieMaxAge), client.ExpiresAfterInactivitySeconds) dbClient, err := s.db.GetClientByToken("Ctesttoken00001") assert.NoError(s.T(), err) diff --git a/api/plugin.go b/api/plugin.go index fcaeeb4c..42f30f69 100644 --- a/api/plugin.go +++ b/api/plugin.go @@ -72,6 +72,7 @@ func (c *PluginAPI) GetPlugins(ctx *gin.Context) { info := c.Manager.PluginInfo(conf.ModulePath) result = append(result, model.PluginConfExternal{ ID: conf.ID, + CreatedAt: conf.CreatedAt, Name: info.String(), Token: conf.Token, ModulePath: conf.ModulePath, diff --git a/api/session.go b/api/session.go index 2722f214..c72b8f55 100644 --- a/api/session.go +++ b/api/session.go @@ -77,10 +77,11 @@ func (a *SessionAPI) Login(ctx *gin.Context) { elevatedUntil := time.Now().Add(model.DefaultElevationDuration) client := model.Client{ - Name: clientParams.Name, - Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), - UserID: user.ID, - ElevatedUntil: &elevatedUntil, + Name: clientParams.Name, + Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + UserID: user.ID, + ElevatedUntil: &elevatedUntil, + ExpiresAfterInactivitySeconds: auth.CookieMaxAge, } if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { return @@ -92,6 +93,7 @@ func (a *SessionAPI) Login(ctx *gin.Context) { ID: user.ID, Name: user.Name, Admin: user.Admin, + CreatedAt: user.CreatedAt, ClientID: client.ID, ElevatedUntil: client.ElevatedUntil, }) diff --git a/api/session_test.go b/api/session_test.go index e69423cb..ebe3b6f3 100644 --- a/api/session_test.go +++ b/api/session_test.go @@ -90,6 +90,7 @@ func (s *SessionSuite) Test_Login_Success() { assert.NoError(s.T(), err) assert.Len(s.T(), clients, 1) assert.Equal(s.T(), "test-browser", clients[0].Name) + assert.Equal(s.T(), uint(auth.CookieMaxAge), clients[0].ExpiresAfterInactivitySeconds) } func (s *SessionSuite) Test_Login_WrongPassword() { diff --git a/api/user.go b/api/user.go index fe8db4e6..e24714e6 100644 --- a/api/user.go +++ b/api/user.go @@ -132,9 +132,10 @@ func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { return } result := &model.CurrentUserExternal{ - ID: user.ID, - Name: user.Name, - Admin: user.Admin, + ID: user.ID, + Name: user.Name, + Admin: user.Admin, + CreatedAt: user.CreatedAt, } client := auth.GetClient(ctx) if client != nil { @@ -460,10 +461,11 @@ func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { return } internal := &model.User{ - ID: oldUser.ID, - Name: user.Name, - Admin: user.Admin, - Pass: oldUser.Pass, + ID: oldUser.ID, + Name: user.Name, + Admin: user.Admin, + Pass: oldUser.Pass, + CreatedAt: oldUser.CreatedAt, } if user.Pass != "" { internal.Pass = password.CreatePassword(user.Pass, a.PasswordStrength) @@ -481,8 +483,9 @@ func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { func toExternalUser(internal *model.User) *model.UserExternal { return &model.UserExternal{ - Name: internal.Name, - Admin: internal.Admin, - ID: internal.ID, + Name: internal.Name, + Admin: internal.Admin, + ID: internal.ID, + CreatedAt: internal.CreatedAt, } } diff --git a/api/user_test.go b/api/user_test.go index f9a8feee..fe44c8c8 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -176,7 +176,7 @@ func (s *UserSuite) Test_CreateUser() { s.a.CreateUser(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) - user := &model.UserExternal{ID: 2, Name: "tom", Admin: true} + user := &model.UserExternal{ID: 2, Name: "tom", Admin: true, CreatedAt: testdb.Now} test.BodyEquals(s.T(), user, s.recorder) if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) { @@ -439,5 +439,5 @@ func (s *UserSuite) noLogin() { } func externalOf(user *model.User) *model.UserExternal { - return &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID} + return &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID, CreatedAt: user.CreatedAt} } diff --git a/app.go b/app.go index 6d117302..b99734d3 100644 --- a/app.go +++ b/app.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "time" "github.com/gotify/server/v2/config" "github.com/gotify/server/v2/database" @@ -39,7 +40,7 @@ func main() { panic(err) } - db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true) + db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true, time.Now) if err != nil { panic(err) } diff --git a/database/client.go b/database/client.go index 51946108..5fe425d6 100644 --- a/database/client.go +++ b/database/client.go @@ -67,3 +67,34 @@ func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) func (d *GormDatabase) UpdateClientElevatedUntil(id uint, t *time.Time) error { return d.DB.Model(&model.Client{}).Where("id = ?", id).Update("elevated_until", t).Error } + +// CleanupExpiredClients deletes clients whose inactivity exceeds their +// expires_after_inactivity_seconds value. This is done in go code, because +// adding seconds to a timestamp is different in the supported sql dialects. +func (d *GormDatabase) CleanupExpiredClients(now time.Time) ([]*model.Client, error) { + var candidates []*model.Client + if err := d.DB.Where("expires_after_inactivity_seconds > 0").Find(&candidates).Error; err != nil { + return nil, err + } + var expired []*model.Client + for _, c := range candidates { + expiresAt := c.GetExpiresAt() + if expiresAt == nil { + continue + } + if now.Sub(*expiresAt) >= 0 { + expired = append(expired, c) + } + } + if len(expired) == 0 { + return nil, nil + } + ids := make([]uint, len(expired)) + for i, c := range expired { + ids[i] = c.ID + } + if err := d.DB.Where("id IN ?", ids).Delete(&model.Client{}).Error; err != nil { + return nil, err + } + return expired, nil +} diff --git a/database/client_test.go b/database/client_test.go index b1dda969..ac61437d 100644 --- a/database/client_test.go +++ b/database/client_test.go @@ -63,3 +63,49 @@ func (s *DatabaseSuite) TestClient() { assert.Nil(s.T(), client) } } + +func (s *DatabaseSuite) TestCleanupExpiredClients() { + user := &model.User{Name: "expiry", Pass: []byte{1}} + s.db.CreateUser(user) + + now := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) + staleDate := now.Add(-2 * time.Hour) + freshDate := now.Add(-5 * time.Second) + + noExpiry := &model.Client{UserID: user.ID, Token: "C0", Name: "never", ExpiresAfterInactivitySeconds: 0, LastUsed: &staleDate} + assert.NoError(s.T(), s.db.CreateClient(noExpiry)) + + lastUsedStale := &model.Client{UserID: user.ID, Token: "C1", Name: "stale", LastUsed: &staleDate, ExpiresAfterInactivitySeconds: 60} + assert.NoError(s.T(), s.db.CreateClient(lastUsedStale)) + + lastUsedFresh := &model.Client{UserID: user.ID, Token: "C2", Name: "fresh", LastUsed: &freshDate, ExpiresAfterInactivitySeconds: 60} + assert.NoError(s.T(), s.db.CreateClient(lastUsedFresh)) + + createdAtFresh := &model.Client{UserID: user.ID, Token: "C3", Name: "unused", ExpiresAfterInactivitySeconds: 60, CreatedAt: freshDate} + assert.NoError(s.T(), s.db.CreateClient(createdAtFresh)) + + createdAtStale := &model.Client{UserID: user.ID, Token: "C4", Name: "unused", ExpiresAfterInactivitySeconds: 60, CreatedAt: staleDate} + assert.NoError(s.T(), s.db.CreateClient(createdAtStale)) + + expired, err := s.db.CleanupExpiredClients(now) + assert.NoError(s.T(), err) + assert.Len(s.T(), expired, 2) + + expiredTokens := []string{} + for _, c := range expired { + expiredTokens = append(expiredTokens, c.Token) + } + assert.Contains(s.T(), expiredTokens, lastUsedStale.Token) + assert.Contains(s.T(), expiredTokens, createdAtStale.Token) + + for _, id := range []uint{noExpiry.ID, lastUsedFresh.ID, createdAtFresh.ID} { + if c, err := s.db.GetClientByID(id); assert.NoError(s.T(), err) { + assert.NotNil(s.T(), c) + } + } + for _, id := range []uint{lastUsedStale.ID, createdAtStale.ID} { + if c, err := s.db.GetClientByID(id); assert.NoError(s.T(), err) { + assert.Nil(s.T(), c) + } + } +} diff --git a/database/database.go b/database/database.go index 574c13ff..0a36edd6 100644 --- a/database/database.go +++ b/database/database.go @@ -24,7 +24,7 @@ import ( var mkdirAll = os.MkdirAll // New creates a new wrapper for the gorm database framework. -func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool) (*GormDatabase, error) { +func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool, now func() time.Time) (*GormDatabase, error) { createDirectoryIfSqlite(dialect, connection) dbLogger := logger.New(log.New(os.Stderr, "\r\n", log.LstdFlags), logger.Config{ @@ -37,6 +37,7 @@ func New(dialect, connection, defaultUser, defaultPass string, strength int, cre Logger: dbLogger, DisableForeignKeyConstraintWhenMigrating: true, TranslateError: true, + NowFunc: now, } var db *gorm.DB @@ -94,9 +95,28 @@ func New(dialect, connection, defaultUser, defaultPass string, strength int, cre return nil, err } + if err := db.Transaction(func(tx *gorm.DB) error { return fillMissingCreatedAt(tx, now()) }, &sql.TxOptions{Isolation: sql.LevelSerializable}); err != nil { + return nil, err + } + return &GormDatabase{DB: db}, nil } +func fillMissingCreatedAt(db *gorm.DB, now time.Time) error { + models := []any{ + new(model.User), + new(model.Application), + new(model.Client), + new(model.PluginConf), + } + for _, m := range models { + if err := db.Model(m).Where("created_at IS NULL").UpdateColumn("created_at", now).Error; err != nil { + return err + } + } + return nil +} + func fillMissingSortKeys(db *gorm.DB) error { missingSort := int64(0) if err := db.Model(new(model.Application)).Where("sort_key IS NULL OR sort_key = ''").Count(&missingSort).Error; err != nil { diff --git a/database/database_test.go b/database/database_test.go index cc962e11..5212f9fb 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -14,6 +14,10 @@ import ( "gorm.io/gorm" ) +var now = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + +func fixedNow() time.Time { return now } + func TestDatabaseSuite(t *testing.T) { suite.Run(t, new(DatabaseSuite)) } @@ -26,7 +30,7 @@ type DatabaseSuite struct { func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { s.tmpDir = test.NewTmpDir("gotify_databasesuite") - db, err := New("sqlite3", s.tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) + db, err := New("sqlite3", s.tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true, fixedNow) assert.Nil(s.T(), err) s.db = db } @@ -39,7 +43,7 @@ func (s *DatabaseSuite) AfterTest(suiteName, testName string) { func TestInvalidDialect(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testinvaliddialect") defer tmpDir.Clean() - _, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) + _, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true, fixedNow) assert.Error(t, err) } @@ -47,7 +51,7 @@ func TestCreateSqliteFolder(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testcreatesqlitefolder") defer tmpDir.Clean() - db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) + db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true, fixedNow) assert.Nil(t, err) assert.DirExists(t, tmpDir.Path("somepath")) db.Close() @@ -57,7 +61,7 @@ func TestWithAlreadyExistingSqliteFolder(t *testing.T) { tmpDir := test.NewTmpDir("gotify_testwithexistingfolder") defer tmpDir.Clean() - db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) + db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true, fixedNow) assert.Nil(t, err) assert.DirExists(t, tmpDir.Path("somepath")) db.Close() @@ -70,12 +74,12 @@ func TestPanicsOnMkdirError(t *testing.T) { return errors.New("ERROR") } assert.Panics(t, func() { - New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true) + New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true, fixedNow) }) } func TestMigrateSortKey(t *testing.T) { - db, err := New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true) + db, err := New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true, fixedNow) assert.Nil(t, err) assert.NotNil(t, db) diff --git a/database/migration_test.go b/database/migration_test.go index dc3d10e4..f097480e 100644 --- a/database/migration_test.go +++ b/database/migration_test.go @@ -43,7 +43,7 @@ func (s *MigrationSuite) AfterTest(suiteName, testName string) { } func (s *MigrationSuite) TestMigration() { - db, err := New("sqlite3", s.tmpDir.Path("test_obsolete.db"), "admin", "admin", 6, true) + db, err := New("sqlite3", s.tmpDir.Path("test_obsolete.db"), "admin", "admin", 6, true, fixedNow) assert.Nil(s.T(), err) defer db.Close() diff --git a/database/user_test.go b/database/user_test.go index 78a5f737..ca2b7e74 100644 --- a/database/user_test.go +++ b/database/user_test.go @@ -52,7 +52,7 @@ func (s *DatabaseSuite) TestUser() { tom, err := s.db.GetUserByID(nicories.ID) require.NoError(s.T(), err) - assert.Equal(s.T(), &model.User{ID: nicories.ID, Name: "tom", Pass: []byte{12}, Admin: true}, tom) + assert.Equal(s.T(), &model.User{ID: nicories.ID, Name: "tom", Pass: []byte{12}, Admin: true, CreatedAt: now}, tom) users, err = s.db.GetUsers() require.NoError(s.T(), err) diff --git a/docs/spec.json b/docs/spec.json index 356d6fde..96780c2d 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2439,9 +2439,18 @@ "description", "internal", "image", + "createdAt", "sortKey" ], "properties": { + "createdAt": { + "description": "The date the application was created.", + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "defaultPriority": { "description": "The default priority of messages sent by this application. Defaults to 0.", "type": "integer", @@ -2550,9 +2559,18 @@ "required": [ "id", "token", - "name" + "name", + "createdAt" ], "properties": { + "createdAt": { + "description": "The date the client was created.", + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "elevatedUntil": { "description": "The time until which this client's session is elevated.", "type": "string", @@ -2560,6 +2578,21 @@ "x-go-name": "ElevatedUntil", "readOnly": true }, + "expiresAfterInactivitySeconds": { + "description": "The number of seconds of inactivity after which the client is removed.\n0 means the client never expires.", + "type": "integer", + "format": "int64", + "x-go-name": "ExpiresAfterInactivitySeconds", + "example": 2592000 + }, + "expiresAt": { + "description": "The time at which this client will expire due to inactivity, or null if it never expires.", + "type": "string", + "format": "date-time", + "x-go-name": "ExpiresAt", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "id": { "description": "The client id.", "type": "integer", @@ -2600,6 +2633,13 @@ "name" ], "properties": { + "expiresAfterInactivitySeconds": { + "description": "The number of seconds of inactivity after which the client is removed.\n0 (or omitted) means the client never expires.", + "type": "integer", + "format": "int64", + "x-go-name": "ExpiresAfterInactivitySeconds", + "example": 2592000 + }, "name": { "description": "The client name", "type": "string", @@ -2646,7 +2686,8 @@ "required": [ "id", "name", - "admin" + "admin", + "createdAt" ], "properties": { "admin": { @@ -2663,6 +2704,14 @@ "readOnly": true, "example": 5 }, + "createdAt": { + "description": "The date the user was created.", + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "elevatedUntil": { "description": "The time until which the session is elevated.", "type": "string", @@ -3046,6 +3095,7 @@ "title": "PluginConfExternal Model", "required": [ "id", + "createdAt", "name", "token", "modulePath", @@ -3072,6 +3122,14 @@ "display" ] }, + "createdAt": { + "description": "The date the plugin was created.", + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "enabled": { "description": "Whether the plugin instance is enabled.", "type": "boolean", @@ -3161,7 +3219,8 @@ "required": [ "id", "name", - "admin" + "admin", + "createdAt" ], "properties": { "admin": { @@ -3170,6 +3229,14 @@ "x-go-name": "Admin", "example": true }, + "createdAt": { + "description": "The date the user was created.", + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "id": { "description": "The user id.", "type": "integer", diff --git a/model/application.go b/model/application.go index 1305a07a..9a8fbb2e 100644 --- a/model/application.go +++ b/model/application.go @@ -49,6 +49,12 @@ type Application struct { // required: false // example: 4 DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` + // The date the application was created. + // + // read only: true + // required: true + // example: 2019-01-01T00:00:00Z + CreatedAt time.Time `json:"createdAt"` // The last time the application token was used. // // read only: true diff --git a/model/client.go b/model/client.go index 674be278..5d9a2d46 100644 --- a/model/client.go +++ b/model/client.go @@ -26,6 +26,12 @@ type Client struct { // required: true // example: Android Phone Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` + // The date the client was created. + // + // read only: true + // required: true + // example: 2019-01-01T00:00:00Z + CreatedAt time.Time `json:"createdAt"` // The last time the client token was used. // // read only: true @@ -35,4 +41,30 @@ type Client struct { // // read only: true ElevatedUntil *time.Time `json:"elevatedUntil,omitempty"` + // The number of seconds of inactivity after which the client is removed. + // 0 means the client never expires. + // + // example: 2592000 + ExpiresAfterInactivitySeconds uint `gorm:"default:0;not null" form:"expiresAfterInactivitySeconds" query:"expiresAfterInactivitySeconds" json:"expiresAfterInactivitySeconds"` + // The time at which this client will expire due to inactivity, or null if it never expires. + // + // read only: true + // example: 2019-01-01T00:00:00Z + ExpiresAt *time.Time `gorm:"-" json:"expiresAt,omitempty"` +} + +func (c *Client) PopulateExpiresAt() { + c.ExpiresAt = c.GetExpiresAt() +} + +func (c *Client) GetExpiresAt() *time.Time { + if c.ExpiresAfterInactivitySeconds == 0 { + return nil + } + reference := c.CreatedAt + if c.LastUsed != nil { + reference = *c.LastUsed + } + expiry := reference.Add(time.Duration(c.ExpiresAfterInactivitySeconds) * time.Second) + return &expiry } diff --git a/model/pluginconf.go b/model/pluginconf.go index 9f798817..dfe4bc27 100644 --- a/model/pluginconf.go +++ b/model/pluginconf.go @@ -1,5 +1,7 @@ package model +import "time" + // PluginConf holds information about the plugin. type PluginConf struct { ID uint `gorm:"primaryKey;autoIncrement"` @@ -8,6 +10,7 @@ type PluginConf struct { Token string `gorm:"type:varchar(180);uniqueIndex:uix_plugin_confs_token"` ApplicationID uint Enabled bool + CreatedAt time.Time Config []byte Storage []byte } @@ -24,6 +27,12 @@ type PluginConfExternal struct { // required: true // example: 25 ID uint `json:"id"` + // The date the plugin was created. + // + // read only: true + // required: true + // example: 2019-01-01T00:00:00Z + CreatedAt time.Time `json:"createdAt"` // The plugin name. // // read only: true diff --git a/model/user.go b/model/user.go index 9648b660..2e70582f 100644 --- a/model/user.go +++ b/model/user.go @@ -8,6 +8,7 @@ type User struct { Name string `gorm:"type:varchar(180);uniqueIndex:uix_users_name"` Pass []byte Admin bool + CreatedAt time.Time Applications []Application Clients []Client Plugins []PluginConf @@ -35,6 +36,12 @@ type UserExternal struct { // required: true // example: true Admin bool `json:"admin" form:"admin" query:"admin"` + // The date the user was created. + // + // read only: true + // required: true + // example: 2019-01-01T00:00:00Z + CreatedAt time.Time `json:"createdAt"` } // CreateUserExternal Model @@ -102,6 +109,12 @@ type CurrentUserExternal struct { // required: true // example: true Admin bool `json:"admin"` + // The date the user was created. + // + // read only: true + // required: true + // example: 2019-01-01T00:00:00Z + CreatedAt time.Time `json:"createdAt"` // The client id of the current session. // // read only: true diff --git a/router/router.go b/router/router.go index cb39af00..596d0306 100644 --- a/router/router.go +++ b/router/router.go @@ -2,6 +2,7 @@ package router import ( "fmt" + "log" "net/http" "path/filepath" "regexp" @@ -71,7 +72,16 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co for range ticker.C { connectedTokens := streamHandler.CollectConnectedClientTokens() now := time.Now() - db.UpdateClientTokensLastUsed(connectedTokens, &now) + if err := db.UpdateClientTokensLastUsed(connectedTokens, &now); err != nil { + log.Println("Error updating last used", err) + } + if expired, err := db.CleanupExpiredClients(now); err == nil { + for _, c := range expired { + streamHandler.NotifyDeletedClient(c.UserID, c.Token) + } + } else { + log.Println("Error cleaning up expired clients", err) + } } }() authentication := auth.Auth{DB: db, SecureCookie: conf.Server.SecureCookie} diff --git a/router/router_test.go b/router/router_test.go index 5c804879..cb2a5833 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -369,7 +369,7 @@ func (s *IntegrationSuite) TestPluginLoadFail_expectPanic() { func (s *IntegrationSuite) TestAuthentication() { req := s.newRequest("GET", "current/user", "") req.SetBasicAuth("admin", "pw") - doRequestAndExpect(s.T(), req, 200, `{"id": 1, "name": "admin", "admin": true}`) + doRequestAndExpect(s.T(), req, 200, `{"id": 1, "name": "admin", "admin": true, "createdAt":"2020-01-01T00:00:00Z"}`) req = s.newRequest("GET", "current/user", "") req.SetBasicAuth("jmattheis", "pw") @@ -377,7 +377,7 @@ func (s *IntegrationSuite) TestAuthentication() { req = s.newRequest("POST", "user", `{"name": "normal", "pass": "secret"}`) req.SetBasicAuth("admin", "pw") - doRequestAndExpect(s.T(), req, 200, `{"id": 2, "name": "normal", "admin": false}`) + doRequestAndExpect(s.T(), req, 200, `{"id": 2, "name": "normal", "admin": false, "createdAt":"2020-01-01T00:00:00Z"}`) req = s.newRequest("POST", "user", `{"name": "normal2", "pass": "secret"}`) req.SetBasicAuth("normal", "secret") @@ -389,7 +389,7 @@ func (s *IntegrationSuite) TestAuthentication() { req = s.newRequest("GET", "current/user", "") req.SetBasicAuth("normal", "secret") - doRequestAndExpect(s.T(), req, 200, `{"id": 2, "name": "normal", "admin": false}`) + doRequestAndExpect(s.T(), req, 200, `{"id": 2, "name": "normal", "admin": false, "createdAt":"2020-01-01T00:00:00Z"}`) req = s.newRequest("POST", "client", `{"name": "android-client"}`) req.SetBasicAuth("normal", "secret") diff --git a/test/testdb/database.go b/test/testdb/database.go index f42a8df1..8897f47c 100644 --- a/test/testdb/database.go +++ b/test/testdb/database.go @@ -10,6 +10,10 @@ import ( "github.com/stretchr/testify/assert" ) +var Now = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + +func nowFunc() time.Time { return Now } + // Database is the wrapper for the gorm database with sleek helper methods. type Database struct { *database.GormDatabase @@ -30,7 +34,7 @@ type MessageBuilder struct { // NewDBWithDefaultUser creates a new test db instance with the default user. func NewDBWithDefaultUser(t *testing.T) *Database { - db, err := database.New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true) + db, err := database.New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true, nowFunc) assert.Nil(t, err) assert.NotNil(t, db) return &Database{GormDatabase: db, t: t} @@ -38,7 +42,7 @@ func NewDBWithDefaultUser(t *testing.T) *Database { // NewDB creates a new test db instance. func NewDB(t *testing.T) *Database { - db, err := database.New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, false) + db, err := database.New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, false, nowFunc) assert.Nil(t, err) assert.NotNil(t, db) return &Database{GormDatabase: db, t: t} diff --git a/test/testdb/database_test.go b/test/testdb/database_test.go index bd1eb5f6..8fe97a3b 100644 --- a/test/testdb/database_test.go +++ b/test/testdb/database_test.go @@ -41,11 +41,15 @@ func (s *DatabaseSuite) Test_Users() { newUserActual := s.db.NewUser(2) s.db.NewUserWithName(3, "tom") - newUserExpected := &model.User{ID: 2, Name: "user2"} + newUserExpected := &model.User{ID: 2, Name: "user2", CreatedAt: testdb.Now} assert.Equal(s.T(), newUserExpected, newUserActual) - users := []*model.User{{ID: 1, Name: "user1"}, {ID: 2, Name: "user2"}, {ID: 3, Name: "tom"}} + users := []*model.User{ + {ID: 1, Name: "user1", CreatedAt: testdb.Now}, + {ID: 2, Name: "user2", CreatedAt: testdb.Now}, + {ID: 3, Name: "tom", CreatedAt: testdb.Now}, + } if usersActual, err := s.db.GetUsers(); assert.NoError(s.T(), err) { assert.Equal(s.T(), users, usersActual) @@ -67,15 +71,18 @@ func (s *DatabaseSuite) Test_Clients() { s.db.User(2).Client(5) - newClientExpected := &model.Client{ID: 2, Token: "asdf", UserID: 1} + newClientExpected := &model.Client{ID: 2, Token: "asdf", UserID: 1, CreatedAt: testdb.Now} assert.Equal(s.T(), newClientExpected, newClientActual) - userOneExpected := []*model.Client{{ID: 1, Token: "client1", UserID: 1}, {ID: 2, Token: "asdf", UserID: 1}} + userOneExpected := []*model.Client{ + {ID: 1, Token: "client1", UserID: 1, CreatedAt: testdb.Now}, + {ID: 2, Token: "asdf", UserID: 1, CreatedAt: testdb.Now}, + } if clients, err := s.db.GetClientsByUser(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), userOneExpected, clients) } - userTwoExpected := []*model.Client{{ID: 5, Token: "client5", UserID: 2}} + userTwoExpected := []*model.Client{{ID: 5, Token: "client5", UserID: 2, CreatedAt: testdb.Now}} if clients, err := s.db.GetClientsByUser(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), userTwoExpected, clients) } @@ -100,31 +107,31 @@ func (s *DatabaseSuite) Test_Apps() { s.db.User(2).InternalApp(5) - newAppExpected := &model.Application{ID: 2, Token: "asdf", UserID: 1, SortKey: "a1"} - newInternalAppExpected := &model.Application{ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2"} + newAppExpected := &model.Application{ID: 2, Token: "asdf", UserID: 1, SortKey: "a1", CreatedAt: testdb.Now} + newInternalAppExpected := &model.Application{ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2", CreatedAt: testdb.Now} assert.Equal(s.T(), newAppExpected, newAppActual) assert.Equal(s.T(), newInternalAppExpected, newInternalAppActual) userOneExpected := []*model.Application{ - {ID: 1, Token: "app1", UserID: 1, SortKey: "a0"}, - {ID: 2, Token: "asdf", UserID: 1, SortKey: "a1"}, - {ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2"}, + {ID: 1, Token: "app1", UserID: 1, SortKey: "a0", CreatedAt: testdb.Now}, + {ID: 2, Token: "asdf", UserID: 1, SortKey: "a1", CreatedAt: testdb.Now}, + {ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2", CreatedAt: testdb.Now}, } if app, err := s.db.GetApplicationsByUser(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), userOneExpected, app) } - userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2, Internal: true, SortKey: "a0"}} + userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2, Internal: true, SortKey: "a0", CreatedAt: testdb.Now}} if app, err := s.db.GetApplicationsByUser(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), userTwoExpected, app) } newAppWithName := userBuilder.NewAppWithTokenAndName(7, "test-token", "app name") - newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name", SortKey: "a3"} + newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name", SortKey: "a3", CreatedAt: testdb.Now} assert.Equal(s.T(), newAppWithNameExpected, newAppWithName) newInternalAppWithName := userBuilder.NewInternalAppWithTokenAndName(8, "test-tokeni", "app name") - newInternalAppWithNameExpected := &model.Application{ID: 8, Token: "test-tokeni", UserID: 1, Name: "app name", Internal: true, SortKey: "a4"} + newInternalAppWithNameExpected := &model.Application{ID: 8, Token: "test-tokeni", UserID: 1, Name: "app name", Internal: true, SortKey: "a4", CreatedAt: testdb.Now} assert.Equal(s.T(), newInternalAppWithNameExpected, newInternalAppWithName) userBuilder.AppWithTokenAndName(9, "test-token-2", "app name") diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts index 35de2f5d..668d0dfa 100644 --- a/ui/src/CurrentUser.ts +++ b/ui/src/CurrentUser.ts @@ -11,7 +11,12 @@ export class CurrentUser { @observable accessor loggedIn = false; @observable accessor refreshKey = 0; @observable accessor authenticating = true; - @observable accessor user: ICurrentUser = {name: 'unknown', admin: false, id: -1}; + @observable accessor user: ICurrentUser = { + name: 'unknown', + admin: false, + id: -1, + createdAt: '', + }; @observable accessor connectionErrorMessage: string | null = null; public constructor(private readonly snack: SnackReporter) {} diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index 570fc932..bda8f07d 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -32,6 +32,8 @@ import * as config from '../config'; import {UpdateApplicationDialog} from './UpdateApplicationDialog'; import {IApplication} from '../types'; import {LastUsedCell} from '../common/LastUsedCell'; +import TimeAgo from 'react-timeago'; +import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; import {makeStyles} from 'tss-react/mui'; @@ -126,6 +128,7 @@ const Applications = observer(() => { Token Description Priority + Created Last Used @@ -258,6 +261,9 @@ const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { {app.description} {app.defaultPriority} + + + diff --git a/ui/src/client/AddClientDialog.tsx b/ui/src/client/AddClientDialog.tsx index 50cac3db..22cb320e 100644 --- a/ui/src/client/AddClientDialog.tsx +++ b/ui/src/client/AddClientDialog.tsx @@ -6,18 +6,20 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; +import {NumberField} from '../common/NumberField'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string) => Promise; + fOnSubmit: (name: string, expiresAfterInactivitySeconds: number) => Promise; } const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { const [name, setName] = useState(''); + const [expiresAfter, setExpiresAfter] = useState(0); const submitEnabled = name.length !== 0; const submitAndClose = async () => { - await fOnSubmit(name); + await fOnSubmit(name, Math.max(0, expiresAfter)); fClose(); }; @@ -35,6 +37,14 @@ const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { onChange={(e) => setName(e.target.value)} fullWidth /> + setExpiresAfter(value)} + fullWidth + /> diff --git a/ui/src/client/ClientStore.ts b/ui/src/client/ClientStore.ts index 2eb34da4..b96dff59 100644 --- a/ui/src/client/ClientStore.ts +++ b/ui/src/client/ClientStore.ts @@ -20,22 +20,35 @@ export class ClientStore extends BaseStore { } @action - public update = async (id: number, name: string): Promise => { - await axios.put(`${config.get('url')}client/${id}`, {name}); + public update = async ( + id: number, + name: string, + expiresAfterInactivitySeconds: number + ): Promise => { + await axios.put(`${config.get('url')}client/${id}`, { + name, + expiresAfterInactivitySeconds, + }); await this.refresh(); this.snack('Client updated'); }; @action - public createNoNotifcation = async (name: string): Promise => { - const client = await axios.post(`${config.get('url')}client`, {name}); + public createNoNotifcation = async ( + name: string, + expiresAfterInactivitySeconds = 0 + ): Promise => { + const client = await axios.post(`${config.get('url')}client`, { + name, + expiresAfterInactivitySeconds, + }); await this.refresh(); return client.data; }; @action - public create = async (name: string): Promise => { - await this.createNoNotifcation(name); + public create = async (name: string, expiresAfterInactivitySeconds = 0): Promise => { + await this.createNoNotifcation(name, expiresAfterInactivitySeconds); this.snack('Client added'); }; diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 6e20a99a..10d63a05 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -57,6 +57,8 @@ const Clients = observer(() => { Token Last Used Elevation ends + Created + Expires in @@ -68,8 +70,10 @@ const Clients = observer(() => { key={client.id} name={client.name} value={client.token} + createdAt={client.createdAt} lastUsed={client.lastUsed} elevatedUntil={client.elevatedUntil} + expiresAt={client.expiresAt} fEdit={() => setToUpdateClient(client)} fDelete={() => setToDeleteClient(client)} fElevate={() => setToElevateClient(client)} @@ -88,8 +92,13 @@ const Clients = observer(() => { {toUpdateClient != null && ( setToUpdateClient(undefined)} - fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)} + fOnSubmit={(name, expiresAfterInactivitySeconds) => + clientStore.update(toUpdateClient.id, name, expiresAfterInactivitySeconds) + } initialName={toUpdateClient.name} + initialExpiresAfterInactivitySeconds={ + toUpdateClient.expiresAfterInactivitySeconds + } /> )} {toDeleteClient != null && ( @@ -115,14 +124,26 @@ const Clients = observer(() => { interface IRowProps { name: string; value: string; + createdAt: string; lastUsed: string | null; elevatedUntil?: string; + expiresAt: string | null; fEdit: VoidFunction; fDelete: VoidFunction; fElevate: VoidFunction; } -const Row = ({name, value, lastUsed, elevatedUntil, fEdit, fDelete, fElevate}: IRowProps) => ( +const Row = ({ + name, + value, + createdAt, + lastUsed, + elevatedUntil, + expiresAt, + fEdit, + fDelete, + fElevate, +}: IRowProps) => ( {name} @@ -141,6 +162,16 @@ const Row = ({name, value, lastUsed, elevatedUntil, fEdit, fDelete, fElevate}: I '-' )} + + + + + {expiresAt ? ( + + ) : ( + '-' + )} + diff --git a/ui/src/client/UpdateClientDialog.tsx b/ui/src/client/UpdateClientDialog.tsx index e6e7dae9..f3459f31 100644 --- a/ui/src/client/UpdateClientDialog.tsx +++ b/ui/src/client/UpdateClientDialog.tsx @@ -7,19 +7,27 @@ import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; +import {NumberField} from '../common/NumberField'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string) => Promise; + fOnSubmit: (name: string, expiresAfterInactivitySeconds: number) => Promise; initialName: string; + initialExpiresAfterInactivitySeconds: number; } -const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => { +const UpdateClientDialog = ({ + fClose, + fOnSubmit, + initialName = '', + initialExpiresAfterInactivitySeconds, +}: IProps) => { const [name, setName] = useState(initialName); + const [expiresAfter, setExpiresAfter] = useState(initialExpiresAfterInactivitySeconds); const submitEnabled = name.length !== 0; const submitAndClose = async () => { - await fOnSubmit(name); + await fOnSubmit(name, Math.max(0, expiresAfter)); fClose(); }; @@ -41,6 +49,14 @@ const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => { onChange={(e) => setName(e.target.value)} fullWidth /> + setExpiresAfter(value)} + fullWidth + /> diff --git a/ui/src/plugin/Plugins.tsx b/ui/src/plugin/Plugins.tsx index 9175715e..5aa3cd85 100644 --- a/ui/src/plugin/Plugins.tsx +++ b/ui/src/plugin/Plugins.tsx @@ -11,6 +11,8 @@ import Settings from '@mui/icons-material/Settings'; import {Switch, Button} from '@mui/material'; import DefaultPage from '../common/DefaultPage'; import CopyableSecret from '../common/CopyableSecret'; +import TimeAgo from 'react-timeago'; +import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; import {observer} from 'mobx-react-lite'; import {IPlugin} from '../types'; import {useStores} from '../stores'; @@ -30,6 +32,7 @@ const Plugins = observer(() => { Enabled Name Token + Created Details @@ -41,6 +44,7 @@ const Plugins = observer(() => { token={plugin.token} name={plugin.name} enabled={plugin.enabled} + createdAt={plugin.createdAt} fToggleStatus={() => pluginStore.changeEnabledState(plugin.id, !plugin.enabled) } @@ -59,32 +63,38 @@ interface IRowProps { name: string; token: string; enabled: boolean; + createdAt: string; fToggleStatus: VoidFunction; } -const Row: React.FC = observer(({name, id, token, enabled, fToggleStatus}) => ( - - {id} - - - - {name} - - - - - - - - - -)); +const Row: React.FC = observer( + ({name, id, token, enabled, createdAt, fToggleStatus}) => ( + + {id} + + + + {name} + + + + + + + + + + + + + ) +); export default Plugins; diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index f84a1e8d..538dbab6 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -19,9 +19,10 @@ enum Col { Token = 4, Description = 5, DefaultPriority = 6, - LastUsed = 7, - EditUpdate = 8, - EditDelete = 9, + Created = 7, + LastUsed = 8, + EditUpdate = 9, + EditDelete = 10, } const hiddenToken = '•••••••••••••••'; diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts index 643b52b5..f4997c8c 100644 --- a/ui/src/tests/client.test.ts +++ b/ui/src/tests/client.test.ts @@ -21,20 +21,35 @@ const waitForClient = await waitForExists(page, $table.cell(row, ClientCol.Name), name); }; -const updateClient = - (id: number, data: {name?: string}): (() => Promise) => +interface ClientFields { + name?: string; + expiresAfter?: number; +} + +const fillClientDialog = + (opener: string, submit: string, data: ClientFields): (() => Promise) => async () => { - await page.click($table.cell(id, ClientCol.Edit, '.edit')); + await page.click(opener); await page.waitForSelector($dialog.selector()); - if (data.name) { + if (data.name !== undefined) { const nameSelector = $dialog.input('.name'); await clearField(page, nameSelector); await page.type(nameSelector, data.name); } - await page.click($dialog.button('.update')); + if (data.expiresAfter !== undefined) { + const expiresSelector = $dialog.input('.expires-after'); + await clearField(page, expiresSelector); + await page.type(expiresSelector, data.expiresAfter.toString()); + } + await page.click($dialog.button(submit)); await waitToDisappear(page, $dialog.selector()); }; +const createClient = (data: ClientFields) => fillClientDialog('#create-client', '.create', data); + +const updateClient = (id: number, data: ClientFields) => + fillClientDialog($table.cell(id, ClientCol.Edit, '.edit'), '.update', data); + const $table = selector.table('#client-table'); const $dialog = selector.form('#client-dialog'); @@ -52,17 +67,8 @@ describe('Client', () => { expect(await count(page, $table.rows())).toBe(1); }); describe('create clients', () => { - const createClient = - (name: string): (() => Promise) => - async () => { - await page.click('#create-client'); - await page.waitForSelector($dialog.selector()); - await page.type($dialog.input('.name'), name); - await page.click($dialog.button('.create')); - await waitToDisappear(page, $dialog.selector()); - }; - it('phone', createClient('phone')); - it('desktop app', createClient('desktop app')); + it('phone', createClient({name: 'phone'})); + it('desktop app', createClient({name: 'desktop app', expiresAfter: 60 * 60})); }); it('has created clients', async () => { await page.waitForSelector($table.row(3)); @@ -73,8 +79,15 @@ describe('Client', () => { expect(await innerText(page, $table.cell(2, ClientCol.Name))).toBe('phone'); expect(await innerText(page, $table.cell(3, ClientCol.Name))).toBe('desktop app'); }); - it('updates client', updateClient(1, {name: 'firefox'})); + it('shows expires after for new clients', async () => { + expect(await innerText(page, $table.cell(2, ClientCol.ExpiresIn))).toBe('-'); + expect(await innerText(page, $table.cell(3, ClientCol.ExpiresIn))).toBe('in 1 hour'); + }); + it('updates client', updateClient(1, {name: 'firefox', expiresAfter: 60 * 60 * 10})); it('has updated client name', waitForClient('firefox', 1)); + it('has updated expires after', async () => { + expect(await innerText(page, $table.cell(1, ClientCol.ExpiresIn))).toBe('in 10 hours'); + }); it('shows token', async () => { await page.click($table.cell(3, ClientCol.Token, '.toggle-visibility')); expect( diff --git a/ui/src/tests/plugin.test.ts b/ui/src/tests/plugin.test.ts index b551cf66..3bb9dc9d 100644 --- a/ui/src/tests/plugin.test.ts +++ b/ui/src/tests/plugin.test.ts @@ -27,7 +27,8 @@ enum Col { SetEnabled = 2, Name = 3, Token = 4, - Details = 5, + Created = 5, + Details = 6, } const hiddenToken = '•••••••••••••••'; diff --git a/ui/src/tests/user.test.ts b/ui/src/tests/user.test.ts index d3e0407d..4d9caceb 100644 --- a/ui/src/tests/user.test.ts +++ b/ui/src/tests/user.test.ts @@ -17,7 +17,8 @@ afterAll(async () => await gotify.close()); enum Col { Name = 1, Admin = 2, - EditDelete = 3, + Created = 3, + EditDelete = 4, } const $table = selector.table('#user-table'); @@ -81,7 +82,7 @@ describe('User', () => { it('changed jmattheis', hasUser('jmattheis', true, 3)); it('changes name of nicories', async () => { - await page.click($table.cell(2, 3, '.edit')); + await page.click($table.cell(2, Col.EditDelete, '.edit')); await page.waitForSelector($dialog.selector()); diff --git a/ui/src/tests/utils.ts b/ui/src/tests/utils.ts index a8aa861a..bb1cc80f 100644 --- a/ui/src/tests/utils.ts +++ b/ui/src/tests/utils.ts @@ -73,7 +73,9 @@ export enum ClientCol { Token = 2, LastSeen = 3, ElevationEnds = 4, - Elevate = 5, - Edit = 6, - Delete = 7, + Created = 5, + ExpiresIn = 6, + Elevate = 7, + Edit = 8, + Delete = 9, } diff --git a/ui/src/types.ts b/ui/src/types.ts index 0ab2849c..c9f0324c 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -8,6 +8,7 @@ export interface IApplication { internal: boolean; defaultPriority: number; lastUsed: string | null; + createdAt: string; } export interface IClient { @@ -16,6 +17,9 @@ export interface IClient { name: string; lastUsed: string | null; elevatedUntil?: string; + createdAt: string; + expiresAfterInactivitySeconds: number; + expiresAt: string | null; } export interface IPlugin { @@ -28,6 +32,7 @@ export interface IPlugin { website?: string; license?: string; capabilities: Array<'webhooker' | 'displayer' | 'configurer' | 'messenger' | 'storager'>; + createdAt: string; } export interface IMessage { @@ -61,6 +66,7 @@ export interface IUser { id: number; name: string; admin: boolean; + createdAt: string; } export interface ICurrentUser extends IUser { diff --git a/ui/src/user/Users.tsx b/ui/src/user/Users.tsx index 4399175f..b1148708 100644 --- a/ui/src/user/Users.tsx +++ b/ui/src/user/Users.tsx @@ -16,18 +16,24 @@ import AddEditDialog from './AddEditUserDialog'; import {IUser} from '../types'; import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; +import TimeAgo from 'react-timeago'; +import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; interface IRowProps { name: string; admin: boolean; + createdAt: string; fDelete: VoidFunction; fEdit: VoidFunction; } -const UserRow: React.FC = ({name, admin, fDelete, fEdit}) => ( +const UserRow: React.FC = ({name, admin, createdAt, fDelete, fEdit}) => ( {name} {admin ? 'Yes' : 'No'} + + + @@ -65,6 +71,7 @@ const Users = observer(() => { Username Admin + Created @@ -74,6 +81,7 @@ const Users = observer(() => { key={user.id} name={user.name} admin={user.admin} + createdAt={user.createdAt} fDelete={() => setDeleteUser(user)} fEdit={() => setEditUser(user)} />