mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-01 19:47:37 +03:00
feat(ui): show user's lastAccess (#3342)
* feat(server): update user's lastAccess * feat(ui): show user's lastAccess
This commit is contained in:
parent
5e8085bf3c
commit
a9334b7787
10 changed files with 148 additions and 11 deletions
|
@ -98,6 +98,7 @@
|
|||
"userName": "Usuário",
|
||||
"isAdmin": "Admin?",
|
||||
"lastLoginAt": "Últ. Login",
|
||||
"lastAccessAt": "Últ. Acesso",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"name": "Nome",
|
||||
"password": "Senha",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue