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)}
/>