mirror of
https://github.com/gotify/server.git
synced 2024-01-28 15:20:56 +03:00
Add last used to client & application
This commit is contained in:
committed by
Jannis Mattheis
parent
a44418265a
commit
7bf80ee6f1
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
15
ui/src/common/LastUsedCell.tsx
Normal file
15
ui/src/common/LastUsedCell.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -18,8 +18,9 @@ enum Col {
|
||||
Token = 3,
|
||||
Description = 4,
|
||||
DefaultPriority = 5,
|
||||
EditUpdate = 6,
|
||||
EditDelete = 7,
|
||||
LastUsed = 6,
|
||||
EditUpdate = 7,
|
||||
EditDelete = 8,
|
||||
}
|
||||
|
||||
const hiddenToken = '•••••••••••••••';
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user