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",
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() {

View File

@@ -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() {

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.
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 == "" {

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
@@ -322,6 +323,39 @@ 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()
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) {
mode.Set(mode.TestDev)

View File

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

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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"`
}

View File

@@ -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"`
}

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.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}

View File

@@ -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},

View File

@@ -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<Stores<'appStore'>> {
@@ -67,6 +68,7 @@ class Applications extends Component<Stores<'appStore'>> {
<TableCell>Token</TableCell>
<TableCell>Description</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Last Used</TableCell>
<TableCell />
<TableCell />
</TableRow>
@@ -80,6 +82,7 @@ class Applications extends Component<Stores<'appStore'>> {
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<IRowProps> = observer(
({name, value, noDelete, description, defaultPriority, fDelete, fUpload, image, fEdit}) => (
({
name,
value,
noDelete,
description,
defaultPriority,
lastUsed,
fDelete,
fUpload,
image,
fEdit,
}) => (
<TableRow>
<TableCell padding="default">
<div style={{display: 'flex'}}>
@@ -174,6 +189,9 @@ const Row: SFC<IRowProps> = observer(
</TableCell>
<TableCell>{description}</TableCell>
<TableCell>{defaultPriority}</TableCell>
<TableCell>
<LastUsedCell lastUsed={lastUsed} />
</TableCell>
<TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="edit">
<Edit />

View File

@@ -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<Stores<'clientStore'>> {
@@ -59,6 +60,7 @@ class Clients extends Component<Stores<'clientStore'>> {
<TableRow style={{textAlign: 'center'}}>
<TableCell>Name</TableCell>
<TableCell style={{width: 200}}>Token</TableCell>
<TableCell>Last Used</TableCell>
<TableCell />
<TableCell />
</TableRow>
@@ -69,6 +71,7 @@ class Clients extends Component<Stores<'clientStore'>> {
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<Stores<'clientStore'>> {
interface IRowProps {
name: string;
value: string;
lastUsed: string | null;
fEdit: VoidFunction;
fDelete: VoidFunction;
}
const Row: SFC<IRowProps> = ({name, value, fEdit, fDelete}) => (
const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
<TableRow>
<TableCell>{name}</TableCell>
<TableCell>
<CopyableSecret
value={value}
style={{display: 'flex', alignItems: 'center', width: 200}}
style={{display: 'flex', alignItems: 'center', width: 250}}
/>
</TableCell>
<TableCell>
<LastUsedCell lastUsed={lastUsed} />
</TableCell>
<TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="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,
Description = 4,
DefaultPriority = 5,
EditUpdate = 6,
EditDelete = 7,
LastUsed = 6,
EditUpdate = 7,
EditDelete = 8,
}
const hiddenToken = '•••••••••••••••';

View File

@@ -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'));

View File

@@ -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 {