diff --git a/api/application_test.go b/api/application_test.go index 1d8ef53..8ca23c2 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -91,8 +91,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() Description: "mydesc", Image: "asd", Internal: true, + LastUsed: nil, } - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { diff --git a/api/client_test.go b/api/client_test.go index 2102934..e2b3f28 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -58,7 +58,7 @@ 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"}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","lastUsed":null}`) } func (s *ClientSuite) Test_CreateClient_mapAllParameters() { diff --git a/api/stream/stream.go b/api/stream/stream.go index e659157..bc5007e 100644 --- a/api/stream/stream.go +++ b/api/stream/stream.go @@ -37,6 +37,19 @@ func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string } } +// CollectConnectedClientTokens returns all tokens of the connected clients. +func (a *API) CollectConnectedClientTokens() []string { + a.lock.RLock() + defer a.lock.RUnlock() + var clients []string + for _, cs := range a.clients { + for _, c := range cs { + clients = append(clients, c.token) + } + } + return uniq(clients) +} + // NotifyDeletedUser closes existing connections for the given user. func (a *API) NotifyDeletedUser(userID uint) error { a.lock.Lock() @@ -155,6 +168,18 @@ func (a *API) Close() { } } +func uniq[T comparable](s []T) []T { + m := make(map[T]struct{}) + for _, v := range s { + m[v] = struct{}{} + } + var r []T + for k := range m { + r = append(r, k) + } + return r +} + func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool { origin := r.Header.Get("origin") if origin == "" { diff --git a/api/stream/stream_test.go b/api/stream/stream_test.go index a64970d..009b10f 100644 --- a/api/stream/stream_test.go +++ b/api/stream/stream_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "sort" "strings" "testing" "time" @@ -56,8 +57,8 @@ func TestWriteMessageFails(t *testing.T) { wsURL := wsURL(server.URL) user := testClient(t, wsURL) - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + waitForConnectedClients(api, 1) + clients := clients(api, 1) assert.NotEmpty(t, clients) @@ -86,13 +87,13 @@ func TestWritePingFails(t *testing.T) { user := testClient(t, wsURL) defer user.conn.Close() - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + waitForConnectedClients(api, 1) + clients := clients(api, 1) assert.NotEmpty(t, clients) - time.Sleep(api.pingPeriod) // waiting for ping + time.Sleep(api.pingPeriod + (50 * time.Millisecond)) // waiting for ping api.Notify(1, &model.MessageExternal{Message: "HI"}) user.expectNoMessage() @@ -147,8 +148,8 @@ func TestCloseClientOnNotReading(t *testing.T) { assert.Nil(t, err) defer ws.Close() - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + waitForConnectedClients(api, 1) + assert.NotEmpty(t, clients(api, 1)) time.Sleep(api.pingPeriod + api.pongTimeout) @@ -167,8 +168,9 @@ func TestMessageDirectlyAfterConnect(t *testing.T) { user := testClient(t, wsURL) defer user.conn.Close() - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + + waitForConnectedClients(api, 1) + api.Notify(1, &model.MessageExternal{Message: "msg"}) user.expectMessage(&model.MessageExternal{Message: "msg"}) } @@ -184,8 +186,9 @@ func TestDeleteClientShouldCloseConnection(t *testing.T) { user := testClient(t, wsURL) defer user.conn.Close() - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + + waitForConnectedClients(api, 1) + api.Notify(1, &model.MessageExternal{Message: "msg"}) user.expectMessage(&model.MessageExternal{Message: "msg"}) @@ -230,8 +233,7 @@ func TestDeleteMultipleClients(t *testing.T) { defer userThreeAndroid.conn.Close() userThree := []*testingClient{userThreeAndroid} - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree)) api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"}) expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...) @@ -294,8 +296,7 @@ func TestDeleteUser(t *testing.T) { defer userThreeAndroid.conn.Close() userThree := []*testingClient{userThreeAndroid} - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree)) api.Notify(1, &model.MessageExternal{ID: 4, Message: "there"}) expectMessage(&model.MessageExternal{ID: 4, Message: "there"}, userOne...) @@ -322,6 +323,43 @@ func TestDeleteUser(t *testing.T) { api.Close() } +func TestCollectConnectedClientTokens(t *testing.T) { + mode.Set(mode.TestDev) + + defer leaktest.Check(t)() + userIDs := []uint{1, 1, 1, 2, 2} + tokens := []string{"1-1", "1-2", "1-2", "2-1", "2-2"} + i := 0 + server, api := bootTestServer(func(context *gin.Context) { + auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) + i++ + }) + defer server.Close() + + wsURL := wsURL(server.URL) + userOneConnOne := testClient(t, wsURL) + defer userOneConnOne.conn.Close() + userOneConnTwo := testClient(t, wsURL) + defer userOneConnTwo.conn.Close() + userOneConnThree := testClient(t, wsURL) + defer userOneConnThree.conn.Close() + waitForConnectedClients(api, 3) + + ret := api.CollectConnectedClientTokens() + sort.Strings(ret) + assert.Equal(t, []string{"1-1", "1-2"}, ret) + + userTwoConnOne := testClient(t, wsURL) + defer userTwoConnOne.conn.Close() + userTwoConnTwo := testClient(t, wsURL) + defer userTwoConnTwo.conn.Close() + waitForConnectedClients(api, 5) + + ret = api.CollectConnectedClientTokens() + sort.Strings(ret) + assert.Equal(t, []string{"1-1", "1-2", "2-1", "2-2"}, ret) +} + func TestMultipleClients(t *testing.T) { mode.Set(mode.TestDev) @@ -354,8 +392,7 @@ func TestMultipleClients(t *testing.T) { defer userThreeAndroid.conn.Close() userThree := []*testingClient{userThreeAndroid} - // the server may take some time to register the client - time.Sleep(100 * time.Millisecond) + waitForConnectedClients(api, len(userOne)+len(userTwo)+len(userThree)) // there should not be messages at the beginning expectNoMessage(userOne...) @@ -474,6 +511,17 @@ func clients(api *API, user uint) []*client { return api.clients[user] } +func countClients(a *API) int { + a.lock.RLock() + defer a.lock.RUnlock() + + var i int + for _, clients := range a.clients { + i += len(clients) + } + return i +} + func testClient(t *testing.T, url string) *testingClient { client := createClient(t, url) startReading(client) @@ -560,3 +608,13 @@ func staticUserID() gin.HandlerFunc { auth.RegisterAuthentication(context, nil, 1, "customtoken") } } + +func waitForConnectedClients(api *API, count int) { + for i := 0; i < 10; i++ { + if countClients(api) == count { + // ok + return + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/auth/authentication.go b/auth/authentication.go index bf0a9ef..295d67b 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -3,6 +3,7 @@ package auth import ( "errors" "strings" + "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth/password" @@ -20,6 +21,8 @@ type Database interface { GetPluginConfByToken(token string) (*model.PluginConf, error) GetUserByName(name string) (*model.User, error) GetUserByID(id uint) (*model.User, error) + UpdateClientTokensLastUsed(tokens []string, t *time.Time) error + UpdateApplicationTokenLastUsed(token string, t *time.Time) error } // Auth is the provider for authentication middleware. @@ -56,10 +59,16 @@ func (a *Auth) RequireClient() gin.HandlerFunc { if user != nil { return true, true, user.ID, nil } - if token, err := a.DB.GetClientByToken(tokenID); err != nil { + if client, err := a.DB.GetClientByToken(tokenID); err != nil { return false, false, 0, err - } else if token != nil { - return true, true, token.UserID, nil + } else if client != nil { + now := time.Now() + if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { + if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil { + return false, false, 0, err + } + } + return true, true, client.UserID, nil } return false, false, 0, nil }) @@ -71,10 +80,16 @@ func (a *Auth) RequireApplicationToken() gin.HandlerFunc { if user != nil { return true, false, 0, nil } - if token, err := a.DB.GetApplicationByToken(tokenID); err != nil { + if app, err := a.DB.GetApplicationByToken(tokenID); err != nil { return false, false, 0, err - } else if token != nil { - return true, true, token.UserID, nil + } else if app != nil { + now := time.Now() + if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { + if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil { + return false, false, 0, err + } + } + return true, true, app.UserID, nil } return false, false, 0, nil }) diff --git a/database/application.go b/database/application.go index 090a966..4ef9c0e 100644 --- a/database/application.go +++ b/database/application.go @@ -1,6 +1,8 @@ package database import ( + "time" + "github.com/gotify/server/v2/model" "github.com/jinzhu/gorm" ) @@ -56,3 +58,8 @@ func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, func (d *GormDatabase) UpdateApplication(app *model.Application) error { return d.DB.Save(app).Error } + +// UpdateApplicationTokenLastUsed updates the last used time of the application token. +func (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *time.Time) error { + return d.DB.Model(&model.Application{}).Where("token = ?", token).Update("last_used", t).Error +} diff --git a/database/application_test.go b/database/application_test.go index 1e5cc61..4c389be 100644 --- a/database/application_test.go +++ b/database/application_test.go @@ -1,6 +1,8 @@ package database import ( + "time" + "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) @@ -40,6 +42,14 @@ func (s *DatabaseSuite) TestApplication() { assert.Equal(s.T(), app, newApp) } + lastUsed := time.Now().Add(-time.Hour) + s.db.UpdateApplicationTokenLastUsed(app.Token, &lastUsed) + newApp, err = s.db.GetApplicationByID(app.ID) + if assert.NoError(s.T(), err) { + assert.Equal(s.T(), lastUsed.Unix(), newApp.LastUsed.Unix()) + } + app.LastUsed = &lastUsed + newApp.Image = "asdasd" assert.NoError(s.T(), s.db.UpdateApplication(newApp)) diff --git a/database/client.go b/database/client.go index aa87d89..8ab5b3f 100644 --- a/database/client.go +++ b/database/client.go @@ -1,6 +1,8 @@ package database import ( + "time" + "github.com/gotify/server/v2/model" "github.com/jinzhu/gorm" ) @@ -55,3 +57,8 @@ func (d *GormDatabase) DeleteClientByID(id uint) error { func (d *GormDatabase) UpdateClient(client *model.Client) error { return d.DB.Save(client).Error } + +// UpdateClientTokensLastUsed updates the last used timestamp of clients. +func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error { + return d.DB.Model(&model.Client{}).Where("token IN (?)", tokens).Update("last_used", t).Error +} diff --git a/database/client_test.go b/database/client_test.go index fa5d366..b1dda96 100644 --- a/database/client_test.go +++ b/database/client_test.go @@ -1,6 +1,8 @@ package database import ( + "time" + "github.com/gotify/server/v2/model" "github.com/stretchr/testify/assert" ) @@ -44,6 +46,13 @@ func (s *DatabaseSuite) TestClient() { assert.Equal(s.T(), updateClient, updatedClient) } + lastUsed := time.Now().Add(-time.Hour) + s.db.UpdateClientTokensLastUsed([]string{client.Token}, &lastUsed) + newClient, err = s.db.GetClientByID(client.ID) + if assert.NoError(s.T(), err) { + assert.Equal(s.T(), lastUsed.Unix(), newClient.LastUsed.Unix()) + } + s.db.DeleteClientByID(client.ID) if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { diff --git a/docs/spec.json b/docs/spec.json index fdbe455..1dd82aa 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2098,6 +2098,14 @@ "readOnly": true, "example": false }, + "lastUsed": { + "description": "The last time the application token was used.", + "type": "string", + "format": "date-time", + "x-go-name": "LastUsed", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "name": { "description": "The application name. This is how the application should be displayed to the user.", "type": "string", @@ -2162,6 +2170,14 @@ "readOnly": true, "example": 5 }, + "lastUsed": { + "description": "The last time the client token was used.", + "type": "string", + "format": "date-time", + "x-go-name": "LastUsed", + "readOnly": true, + "example": "2019-01-01T00:00:00Z" + }, "name": { "description": "The client name. This is how the client should be displayed to the user.", "type": "string", diff --git a/model/application.go b/model/application.go index 3c69e7f..0a45f2e 100644 --- a/model/application.go +++ b/model/application.go @@ -1,5 +1,7 @@ package model +import "time" + // Application Model // // The Application holds information about an app which can send notifications. @@ -47,4 +49,9 @@ type Application struct { // required: false // example: 4 DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` + // The last time the application token was used. + // + // read only: true + // example: 2019-01-01T00:00:00Z + LastUsed *time.Time `json:"lastUsed"` } diff --git a/model/client.go b/model/client.go index b378190..c858165 100644 --- a/model/client.go +++ b/model/client.go @@ -1,5 +1,7 @@ package model +import "time" + // Client Model // // The Client holds information about a device which can receive notifications (and other stuff). @@ -24,4 +26,9 @@ type Client struct { // required: true // example: Android Phone Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` + // The last time the client token was used. + // + // read only: true + // example: 2019-01-01T00:00:00Z + LastUsed *time.Time `json:"lastUsed"` } diff --git a/router/router.go b/router/router.go index d2a77b2..b9bd6c2 100644 --- a/router/router.go +++ b/router/router.go @@ -29,7 +29,16 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co g.Use(gin.LoggerWithFormatter(logFormatter), gin.Recovery(), gerror.Handler(), location.Default()) g.NoRoute(gerror.NotFound()) - streamHandler := stream.New(time.Duration(conf.Server.Stream.PingPeriodSeconds)*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins) + streamHandler := stream.New( + time.Duration(conf.Server.Stream.PingPeriodSeconds)*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins) + go func() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + connectedTokens := streamHandler.CollectConnectedClientTokens() + now := time.Now() + db.UpdateClientTokensLastUsed(connectedTokens, &now) + } + }() authentication := auth.Auth{DB: db} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} healthHandler := api.HealthAPI{DB: db} diff --git a/router/router_test.go b/router/router_test.go index 9eb67da..5c80487 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -38,6 +38,7 @@ func (s *IntegrationSuite) BeforeTest(string, string) { var err error s.db = testdb.NewDBWithDefaultUser(s.T()) assert.Nil(s.T(), err) + g, closable := Create(s.db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &config.Configuration{PassStrength: 5}, diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index a859b83..561e997 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -21,6 +21,7 @@ import {inject, Stores} from '../inject'; import * as config from '../config'; import UpdateDialog from './UpdateApplicationDialog'; import {IApplication} from '../types'; +import {LastUsedCell} from '../common/LastUsedCell'; @observer class Applications extends Component> { @@ -67,6 +68,7 @@ class Applications extends Component> { Token Description Priority + Last Used @@ -80,6 +82,7 @@ class Applications extends Component> { image={app.image} name={app.name} value={app.token} + lastUsed={app.lastUsed} fUpload={() => this.uploadImage(app.id)} fDelete={() => (this.deleteId = app.id)} fEdit={() => (this.updateId = app.id)} @@ -151,6 +154,7 @@ interface IRowProps { noDelete: boolean; description: string; defaultPriority: number; + lastUsed: string | null; fUpload: VoidFunction; image: string; fDelete: VoidFunction; @@ -158,7 +162,18 @@ interface IRowProps { } const Row: SFC = observer( - ({name, value, noDelete, description, defaultPriority, fDelete, fUpload, image, fEdit}) => ( + ({ + name, + value, + noDelete, + description, + defaultPriority, + lastUsed, + fDelete, + fUpload, + image, + fEdit, + }) => (
@@ -174,6 +189,9 @@ const Row: SFC = observer( {description} {defaultPriority} + + + diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 7f2b4f8..4fc29eb 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -19,6 +19,7 @@ import {observable} from 'mobx'; import {inject, Stores} from '../inject'; import {IClient} from '../types'; import CopyableSecret from '../common/CopyableSecret'; +import {LastUsedCell} from '../common/LastUsedCell'; @observer class Clients extends Component> { @@ -59,6 +60,7 @@ class Clients extends Component> { Name Token + Last Used @@ -69,6 +71,7 @@ class Clients extends Component> { key={client.id} name={client.name} value={client.token} + lastUsed={client.lastUsed} fEdit={() => (this.updateId = client.id)} fDelete={() => (this.deleteId = client.id)} /> @@ -106,19 +109,23 @@ class Clients extends Component> { interface IRowProps { name: string; value: string; + lastUsed: string | null; fEdit: VoidFunction; fDelete: VoidFunction; } -const Row: SFC = ({name, value, fEdit, fDelete}) => ( +const Row: SFC = ({name, value, lastUsed, fEdit, fDelete}) => ( {name} + + + diff --git a/ui/src/common/LastUsedCell.tsx b/ui/src/common/LastUsedCell.tsx new file mode 100644 index 0000000..db3be73 --- /dev/null +++ b/ui/src/common/LastUsedCell.tsx @@ -0,0 +1,15 @@ +import {Typography} from '@material-ui/core'; +import React from 'react'; +import TimeAgo from 'react-timeago'; + +export const LastUsedCell: React.FC<{lastUsed: string | null}> = ({lastUsed}) => { + if (lastUsed === null) { + return Never; + } + + if (+new Date(lastUsed) + 300000 > Date.now()) { + return Recently; + } + + return ; +}; diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index da95624..ee99b6b 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -18,8 +18,9 @@ enum Col { Token = 3, Description = 4, DefaultPriority = 5, - EditUpdate = 6, - EditDelete = 7, + LastUsed = 6, + EditUpdate = 7, + EditDelete = 8, } const hiddenToken = '•••••••••••••••'; diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts index 6fb8285..6f08b00 100644 --- a/ui/src/tests/client.test.ts +++ b/ui/src/tests/client.test.ts @@ -17,8 +17,9 @@ afterAll(async () => await gotify.close()); enum Col { Name = 1, Token = 2, - Edit = 3, - Delete = 4, + LastSeen = 3, + Edit = 4, + Delete = 5, } const hasClient = @@ -83,6 +84,9 @@ describe('Client', () => { await page.click($table.cell(3, Col.Token, '.toggle-visibility')); expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy(); }); + it('shows last seen', async () => { + expect(await innerText(page, $table.cell(3, Col.LastSeen))).toBeTruthy(); + }); it('deletes client', async () => { await page.click($table.cell(2, Col.Delete, '.delete')); diff --git a/ui/src/types.ts b/ui/src/types.ts index 8e24d15..fbc592c 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -6,12 +6,14 @@ export interface IApplication { image: string; internal: boolean; defaultPriority: number; + lastUsed: string | null; } export interface IClient { id: number; token: string; name: string; + lastUsed: string | null; } export interface IPlugin {