feat(ui): show user's lastAccess (#3342)

* feat(server): update user's lastAccess

* feat(ui): show user's lastAccess
This commit is contained in:
Deluan Quintão 2024-09-30 20:46:10 -04:00 committed by GitHub
parent 5e8085bf3c
commit a9334b7787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 148 additions and 11 deletions

View file

@ -98,6 +98,7 @@
"userName": "Usuário",
"isAdmin": "Admin?",
"lastLoginAt": "Últ. Login",
"lastAccessAt": "Últ. Acesso",
"updatedAt": "Últ. Atualização",
"name": "Nome",
"password": "Senha",

View file

@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@ -18,8 +19,10 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/unrolled/secure"
"golang.org/x/time/rate"
)
func requestLogger(next http.Handler) http.Handler {
@ -298,3 +301,42 @@ func URLParamsMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
var userAccessLimiter idLimiterMap
func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
usr, ok := request.UserFrom(ctx)
if ok {
userAccessLimiter.Do(usr.ID, func() {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err := ds.User(ctx).UpdateLastAccessAt(usr.ID)
if err != nil {
log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "Update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start))
}
})
}
next.ServeHTTP(w, r)
})
}
}
// idLimiterMap is a thread-safe map that stores rate.Sometimes limiters for each user ID.
// Used to make the map type and thread safe.
type idLimiterMap struct {
sm sync.Map
}
func (m *idLimiterMap) Do(id string, f func()) {
limiter, _ := m.sm.LoadOrStore(id, &rate.Sometimes{Interval: 2 * time.Second})
limiter.(*rate.Sometimes).Do(f)
}

View file

@ -1,16 +1,21 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -327,4 +332,78 @@ var _ = Describe("middlewares", func() {
})
})
})
Describe("UpdateLastAccessMiddleware", func() {
var (
middleware func(next http.Handler) http.Handler
req *http.Request
ctx context.Context
ds *tests.MockDataStore
id string
lastAccessTime time.Time
)
callMiddleware := func(req *http.Request) {
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(nil, req)
}
BeforeEach(func() {
id = uuid.NewString()
ds = &tests.MockDataStore{}
lastAccessTime = time.Now()
Expect(ds.User(ctx).Put(&model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})).
To(Succeed())
middleware = UpdateLastAccessMiddleware(ds)
ctx = request.WithUser(
context.Background(),
model.User{ID: id, UserName: "johndoe"},
)
req, _ = http.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(ctx)
})
Context("when the request has a user", func() {
It("does calls the next handler", func() {
called := false
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})).ServeHTTP(nil, req)
Expect(called).To(BeTrue())
})
It("updates the last access time", func() {
time.Sleep(3 * time.Millisecond)
callMiddleware(req)
user, _ := ds.MockedUser.FindByUsername("johndoe")
Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second))
})
It("skip fast successive requests", func() {
// First request
callMiddleware(req)
user, _ := ds.MockedUser.FindByUsername("johndoe")
lastAccessTime = *user.LastAccessAt // Store the last access time
// Second request
time.Sleep(3 * time.Millisecond)
callMiddleware(req)
// The second request should not have changed the last access time
user, _ = ds.MockedUser.FindByUsername("johndoe")
Expect(user.LastAccessAt).To(Equal(&lastAccessTime))
})
})
Context("when the request has no user", func() {
It("does not update the last access time", func() {
req = req.WithContext(context.Background())
callMiddleware(req)
usr, _ := ds.MockedUser.FindByUsername("johndoe")
Expect(usr.LastAccessAt).To(Equal(&lastAccessTime))
})
})
})
})

View file

@ -35,6 +35,7 @@ func (n *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(server.Authenticator(n.ds))
r.Use(server.JWTRefresher)
r.Use(server.UpdateLastAccessMiddleware(n.ds))
n.R(r, "/user", model.User{}, true)
n.R(r, "/song", model.MediaFile{}, false)
n.R(r, "/album", model.Album{}, false)

View file

@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
@ -71,7 +72,8 @@ func (api *Router) routes() http.Handler {
r.Use(postFormToQueryParams)
r.Use(checkRequiredParameters)
r.Use(authenticate(api.ds))
// TODO Validate version
r.Use(server.UpdateLastAccessMiddleware(api.ds))
// TODO Validate API version?
// Subsonic endpoints, grouped by controller
r.Group(func(r chi.Router) {

View file

@ -119,14 +119,6 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
return
}
// TODO: Find a way to update LastAccessAt without causing too much retention in the DB
//go func() {
// err := ds.User(ctx).UpdateLastAccessAt(usr.ID)
// if err != nil {
// log.Error(ctx, "Could not update user's lastAccessAt", "user", usr.UserName)
// }
//}()
ctx = request.WithUser(ctx, *usr)
next.ServeHTTP(w, r.WithContext(ctx))
})

View file

@ -3,8 +3,10 @@ package tests
import (
"encoding/base64"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
)
func CreateMockUserRepo() *MockedUserRepo {
@ -54,5 +56,21 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use
}
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
usr.LastLoginAt = gg.P(time.Now())
return nil
}
}
return u.Error
}
func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
usr.LastAccessAt = gg.P(time.Now())
return nil
}
}
return u.Error
}

View file

@ -97,7 +97,8 @@
"fields": {
"userName": "Username",
"isAdmin": "Is Admin",
"lastLoginAt": "Last Login At",
"lastLoginAt": "Last Login",
"lastAccessAt": "Last Access",
"updatedAt": "Updated at",
"name": "Name",
"password": "Password",

View file

@ -140,7 +140,7 @@ const UserEdit = (props) => {
<BooleanInput source="isAdmin" initialValue={false} />
)}
<DateField variant="body1" source="lastLoginAt" showTime />
{/*<DateField source="lastAccessAt" showTime />*/}
<DateField variant="body1" source="lastAccessAt" showTime />
<DateField variant="body1" source="updatedAt" showTime />
<DateField variant="body1" source="createdAt" showTime />
</SimpleForm>

View file

@ -41,6 +41,7 @@ const UserList = (props) => {
<TextField source="name" />
<BooleanField source="isAdmin" />
<DateField source="lastLoginAt" sortByOrder={'DESC'} />
<DateField source="lastAccessAt" sortByOrder={'DESC'} />
<DateField source="updatedAt" sortByOrder={'DESC'} />
</Datagrid>
)}