Add last used to client & application

This commit is contained in:
eternal-flame-AD
2023-07-23 18:47:48 -05:00
committed by Jannis Mattheis
parent a44418265a
commit 7bf80ee6f1
20 changed files with 211 additions and 16 deletions

View File

@@ -91,8 +91,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation()
Description: "mydesc", Description: "mydesc",
Image: "asd", Image: "asd",
Internal: true, 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() { func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {

View File

@@ -58,7 +58,7 @@ func (s *ClientSuite) AfterTest(suiteName, testName string) {
func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() { func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {
actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"} 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() { func (s *ClientSuite) Test_CreateClient_mapAllParameters() {

View File

@@ -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. // NotifyDeletedUser closes existing connections for the given user.
func (a *API) NotifyDeletedUser(userID uint) error { func (a *API) NotifyDeletedUser(userID uint) error {
a.lock.Lock() 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 { func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {
origin := r.Header.Get("origin") origin := r.Header.Get("origin")
if origin == "" { if origin == "" {

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sort"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -322,6 +323,39 @@ func TestDeleteUser(t *testing.T) {
api.Close() 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()
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()
ret = api.CollectConnectedClientTokens()
sort.Strings(ret)
assert.Equal(t, []string{"1-1", "1-2", "2-1", "2-2"}, ret)
}
func TestMultipleClients(t *testing.T) { func TestMultipleClients(t *testing.T) {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)

View File

@@ -3,6 +3,7 @@ package auth
import ( import (
"errors" "errors"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/auth/password"
@@ -20,6 +21,8 @@ type Database interface {
GetPluginConfByToken(token string) (*model.PluginConf, error) GetPluginConfByToken(token string) (*model.PluginConf, error)
GetUserByName(name string) (*model.User, error) GetUserByName(name string) (*model.User, error)
GetUserByID(id uint) (*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. // Auth is the provider for authentication middleware.
@@ -56,10 +59,16 @@ func (a *Auth) RequireClient() gin.HandlerFunc {
if user != nil { if user != nil {
return true, true, user.ID, 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 return false, false, 0, err
} else if token != nil { } else if client != nil {
return true, true, token.UserID, 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 return false, false, 0, nil
}) })
@@ -71,10 +80,16 @@ func (a *Auth) RequireApplicationToken() gin.HandlerFunc {
if user != nil { if user != nil {
return true, false, 0, 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 return false, false, 0, err
} else if token != nil { } else if app != nil {
return true, true, token.UserID, 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 return false, false, 0, nil
}) })

View File

@@ -1,6 +1,8 @@
package database package database
import ( import (
"time"
"github.com/gotify/server/v2/model" "github.com/gotify/server/v2/model"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
) )
@@ -56,3 +58,8 @@ func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application,
func (d *GormDatabase) UpdateApplication(app *model.Application) error { func (d *GormDatabase) UpdateApplication(app *model.Application) error {
return d.DB.Save(app).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
}

View File

@@ -1,6 +1,8 @@
package database package database
import ( import (
"time"
"github.com/gotify/server/v2/model" "github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -40,6 +42,14 @@ func (s *DatabaseSuite) TestApplication() {
assert.Equal(s.T(), app, newApp) 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" newApp.Image = "asdasd"
assert.NoError(s.T(), s.db.UpdateApplication(newApp)) assert.NoError(s.T(), s.db.UpdateApplication(newApp))

View File

@@ -1,6 +1,8 @@
package database package database
import ( import (
"time"
"github.com/gotify/server/v2/model" "github.com/gotify/server/v2/model"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
) )
@@ -55,3 +57,8 @@ func (d *GormDatabase) DeleteClientByID(id uint) error {
func (d *GormDatabase) UpdateClient(client *model.Client) error { func (d *GormDatabase) UpdateClient(client *model.Client) error {
return d.DB.Save(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
}

View File

@@ -1,6 +1,8 @@
package database package database
import ( import (
"time"
"github.com/gotify/server/v2/model" "github.com/gotify/server/v2/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -44,6 +46,13 @@ func (s *DatabaseSuite) TestClient() {
assert.Equal(s.T(), updateClient, updatedClient) 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) s.db.DeleteClientByID(client.ID)
if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) { if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {

View File

@@ -2098,6 +2098,14 @@
"readOnly": true, "readOnly": true,
"example": false "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": { "name": {
"description": "The application name. This is how the application should be displayed to the user.", "description": "The application name. This is how the application should be displayed to the user.",
"type": "string", "type": "string",
@@ -2162,6 +2170,14 @@
"readOnly": true, "readOnly": true,
"example": 5 "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": { "name": {
"description": "The client name. This is how the client should be displayed to the user.", "description": "The client name. This is how the client should be displayed to the user.",
"type": "string", "type": "string",

View File

@@ -1,5 +1,7 @@
package model package model
import "time"
// Application Model // Application Model
// //
// The Application holds information about an app which can send notifications. // The Application holds information about an app which can send notifications.
@@ -47,4 +49,9 @@ type Application struct {
// required: false // required: false
// example: 4 // example: 4
DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` 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"`
} }

View File

@@ -1,5 +1,7 @@
package model package model
import "time"
// Client Model // Client Model
// //
// The Client holds information about a device which can receive notifications (and other stuff). // The Client holds information about a device which can receive notifications (and other stuff).
@@ -24,4 +26,9 @@ type Client struct {
// required: true // required: true
// example: Android Phone // example: Android Phone
Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"` 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"`
} }

View File

@@ -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.Use(gin.LoggerWithFormatter(logFormatter), gin.Recovery(), gerror.Handler(), location.Default())
g.NoRoute(gerror.NotFound()) 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} authentication := auth.Auth{DB: db}
messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db}
healthHandler := api.HealthAPI{DB: db} healthHandler := api.HealthAPI{DB: db}

View File

@@ -38,6 +38,7 @@ func (s *IntegrationSuite) BeforeTest(string, string) {
var err error var err error
s.db = testdb.NewDBWithDefaultUser(s.T()) s.db = testdb.NewDBWithDefaultUser(s.T())
assert.Nil(s.T(), err) assert.Nil(s.T(), err)
g, closable := Create(s.db.GormDatabase, g, closable := Create(s.db.GormDatabase,
&model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"},
&config.Configuration{PassStrength: 5}, &config.Configuration{PassStrength: 5},

View File

@@ -21,6 +21,7 @@ import {inject, Stores} from '../inject';
import * as config from '../config'; import * as config from '../config';
import UpdateDialog from './UpdateApplicationDialog'; import UpdateDialog from './UpdateApplicationDialog';
import {IApplication} from '../types'; import {IApplication} from '../types';
import {LastUsedCell} from '../common/LastUsedCell';
@observer @observer
class Applications extends Component<Stores<'appStore'>> { class Applications extends Component<Stores<'appStore'>> {
@@ -67,6 +68,7 @@ class Applications extends Component<Stores<'appStore'>> {
<TableCell>Token</TableCell> <TableCell>Token</TableCell>
<TableCell>Description</TableCell> <TableCell>Description</TableCell>
<TableCell>Priority</TableCell> <TableCell>Priority</TableCell>
<TableCell>Last Used</TableCell>
<TableCell /> <TableCell />
<TableCell /> <TableCell />
</TableRow> </TableRow>
@@ -80,6 +82,7 @@ class Applications extends Component<Stores<'appStore'>> {
image={app.image} image={app.image}
name={app.name} name={app.name}
value={app.token} value={app.token}
lastUsed={app.lastUsed}
fUpload={() => this.uploadImage(app.id)} fUpload={() => this.uploadImage(app.id)}
fDelete={() => (this.deleteId = app.id)} fDelete={() => (this.deleteId = app.id)}
fEdit={() => (this.updateId = app.id)} fEdit={() => (this.updateId = app.id)}
@@ -151,6 +154,7 @@ interface IRowProps {
noDelete: boolean; noDelete: boolean;
description: string; description: string;
defaultPriority: number; defaultPriority: number;
lastUsed: string | null;
fUpload: VoidFunction; fUpload: VoidFunction;
image: string; image: string;
fDelete: VoidFunction; fDelete: VoidFunction;
@@ -158,7 +162,18 @@ interface IRowProps {
} }
const Row: SFC<IRowProps> = observer( const Row: SFC<IRowProps> = observer(
({name, value, noDelete, description, defaultPriority, fDelete, fUpload, image, fEdit}) => ( ({
name,
value,
noDelete,
description,
defaultPriority,
lastUsed,
fDelete,
fUpload,
image,
fEdit,
}) => (
<TableRow> <TableRow>
<TableCell padding="default"> <TableCell padding="default">
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
@@ -174,6 +189,9 @@ const Row: SFC<IRowProps> = observer(
</TableCell> </TableCell>
<TableCell>{description}</TableCell> <TableCell>{description}</TableCell>
<TableCell>{defaultPriority}</TableCell> <TableCell>{defaultPriority}</TableCell>
<TableCell>
<LastUsedCell lastUsed={lastUsed} />
</TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="edit"> <IconButton onClick={fEdit} className="edit">
<Edit /> <Edit />

View File

@@ -19,6 +19,7 @@ import {observable} from 'mobx';
import {inject, Stores} from '../inject'; import {inject, Stores} from '../inject';
import {IClient} from '../types'; import {IClient} from '../types';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import {LastUsedCell} from '../common/LastUsedCell';
@observer @observer
class Clients extends Component<Stores<'clientStore'>> { class Clients extends Component<Stores<'clientStore'>> {
@@ -59,6 +60,7 @@ class Clients extends Component<Stores<'clientStore'>> {
<TableRow style={{textAlign: 'center'}}> <TableRow style={{textAlign: 'center'}}>
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell style={{width: 200}}>Token</TableCell> <TableCell style={{width: 200}}>Token</TableCell>
<TableCell>Last Used</TableCell>
<TableCell /> <TableCell />
<TableCell /> <TableCell />
</TableRow> </TableRow>
@@ -69,6 +71,7 @@ class Clients extends Component<Stores<'clientStore'>> {
key={client.id} key={client.id}
name={client.name} name={client.name}
value={client.token} value={client.token}
lastUsed={client.lastUsed}
fEdit={() => (this.updateId = client.id)} fEdit={() => (this.updateId = client.id)}
fDelete={() => (this.deleteId = client.id)} fDelete={() => (this.deleteId = client.id)}
/> />
@@ -106,19 +109,23 @@ class Clients extends Component<Stores<'clientStore'>> {
interface IRowProps { interface IRowProps {
name: string; name: string;
value: string; value: string;
lastUsed: string | null;
fEdit: VoidFunction; fEdit: VoidFunction;
fDelete: VoidFunction; fDelete: VoidFunction;
} }
const Row: SFC<IRowProps> = ({name, value, fEdit, fDelete}) => ( const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell> <TableCell>
<CopyableSecret <CopyableSecret
value={value} value={value}
style={{display: 'flex', alignItems: 'center', width: 200}} style={{display: 'flex', alignItems: 'center', width: 250}}
/> />
</TableCell> </TableCell>
<TableCell>
<LastUsedCell lastUsed={lastUsed} />
</TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="edit"> <IconButton onClick={fEdit} className="edit">
<Edit /> <Edit />

View File

@@ -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 <Typography>Never</Typography>;
}
if (+new Date(lastUsed) + 300000 > Date.now()) {
return <Typography title={lastUsed}>Recently</Typography>;
}
return <TimeAgo date={lastUsed} />;
};

View File

@@ -18,8 +18,9 @@ enum Col {
Token = 3, Token = 3,
Description = 4, Description = 4,
DefaultPriority = 5, DefaultPriority = 5,
EditUpdate = 6, LastUsed = 6,
EditDelete = 7, EditUpdate = 7,
EditDelete = 8,
} }
const hiddenToken = '•••••••••••••••'; const hiddenToken = '•••••••••••••••';

View File

@@ -17,8 +17,9 @@ afterAll(async () => await gotify.close());
enum Col { enum Col {
Name = 1, Name = 1,
Token = 2, Token = 2,
Edit = 3, LastSeen = 3,
Delete = 4, Edit = 4,
Delete = 5,
} }
const hasClient = const hasClient =
@@ -83,6 +84,9 @@ describe('Client', () => {
await page.click($table.cell(3, Col.Token, '.toggle-visibility')); await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy(); 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 () => { it('deletes client', async () => {
await page.click($table.cell(2, Col.Delete, '.delete')); await page.click($table.cell(2, Col.Delete, '.delete'));

View File

@@ -6,12 +6,14 @@ export interface IApplication {
image: string; image: string;
internal: boolean; internal: boolean;
defaultPriority: number; defaultPriority: number;
lastUsed: string | null;
} }
export interface IClient { export interface IClient {
id: number; id: number;
token: string; token: string;
name: string; name: string;
lastUsed: string | null;
} }
export interface IPlugin { export interface IPlugin {