From b9edd344effa4e9ac2e1f9fea000e9016c9c0c6b Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Mon, 19 Jan 2026 15:26:49 -0700 Subject: [PATCH] rewrote session management code entirely to try and eliminate map race conditions --- main.go | 7 +- ui/amcontext.go | 44 ++++--- ui/amsession.go | 139 +++++++++++++++++++++ ui/session_mgr.go | 306 ---------------------------------------------- 4 files changed, 163 insertions(+), 333 deletions(-) delete mode 100644 ui/session_mgr.go diff --git a/main.go b/main.go index 172183a..0a7c056 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,6 @@ import ( "git.erbosoft.com/amy/amsterdam/htmlcheck" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" - "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" log "github.com/sirupsen/logrus" @@ -43,8 +42,8 @@ func setupEcho() *echo.Echo { } else { log.Warn("WARNING: --debug-panic in effect - DO NOT use this in production!") } - e.Use(LogrusMiddleware, session.Middleware(ui.SessionStore)) - e.Use(ui.ContextCreator, ui.IPBanTest, ui.CookieLoginTest) + e.Use(LogrusMiddleware, ui.SessionStoreInjector, ui.ContextCreator) + e.Use(ui.IPBanTest, ui.CookieLoginTest) fn := ui.AmWrap(NotImplPage) e.GET("/TODO/*", fn) @@ -120,7 +119,7 @@ func main() { defer closer() htmlcheck.SetupDicts() ui.SetupTemplates() - closer = ui.SetupSessionManager() + closer = ui.SetupAmSessionManager() defer closer() closer = ui.SetupAmContext() defer closer() diff --git a/ui/amcontext.go b/ui/amcontext.go index e8a682c..948ee5a 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -22,8 +22,6 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/util" "github.com/CloudyKit/jet/v6" - "github.com/gorilla/sessions" - "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" log "github.com/sirupsen/logrus" ) @@ -88,7 +86,7 @@ type amContext struct { httprc int rendervars jet.VarMap outputType string - session *sessions.Session + session AmSession globals *database.Globals globalFlags *util.OptionSet user *database.User @@ -118,7 +116,7 @@ func (c *amContext) ClearLoginCookie() { // ClearSession clears the current session. func (c *amContext) ClearSession() { - AmResetSession(c.echoContext.Request().Context(), c.session) + c.session.Reset(c.echoContext.Request().Context()) c.user = nil c.effectiveLevel = 0 } @@ -131,7 +129,7 @@ func (c *amContext) Ctx() context.Context { // CurrentCommunity returns the current community, if one's been set. func (c *amContext) CurrentCommunity() *database.Community { if c.community == nil { - cv, ok := AmSessionGet(c.session, "lastCommunity") + cv, ok := c.session.Get("lastCommunity") if ok && !c.CurrentUser().IsAnon { c.SetCommunityContext(fmt.Sprintf("%d", cv)) } @@ -142,7 +140,7 @@ func (c *amContext) CurrentCommunity() *database.Community { // CurrentUser returns the current user from the session. func (c *amContext) CurrentUser() *database.User { if c.user == nil { - id, ok := AmSessionUid(c.session) + id, ok := c.session.Uid() var err error var u *database.User if ok { @@ -161,8 +159,7 @@ func (c *amContext) CurrentUser() *database.User { // CurrentUserId returns the current user ID. func (c *amContext) CurrentUserId() int32 { - rc, ok := AmSessionUid(c.session) - if ok { + if rc, ok := c.session.Uid(); ok { return rc } u, err := database.AmGetAnonUser(c.echoContext.Request().Context()) @@ -252,7 +249,7 @@ func (c *amContext) IsMemberLocked() bool { // LeftMenu returns the current left menu selector. func (c *amContext) LeftMenu() string { - rc, ok := AmSessionGet(c.session, "leftMenu") + rc, ok := c.session.Get("leftMenu") if ok { return rc.(string) } else { @@ -307,7 +304,7 @@ func (c *amContext) RemoteIP() string { * u - New user to associate with the context. */ func (c *amContext) ReplaceUser(u *database.User) { - AmSetSessionUser(c.session, u) + c.session.SetUser(u) c.user = u c.effectiveLevel = u.BaseLevel } @@ -340,7 +337,7 @@ func (c *amContext) SetCommunityContext(param string) error { c.effectiveLevel = level } if mbr { - AmSessionPut(c.session, "lastCommunity", comm.Id) + c.session.Set("lastCommunity", comm.Id) } } return nil @@ -348,7 +345,7 @@ func (c *amContext) SetCommunityContext(param string) error { // SetLeftMenu sets the current topmost left menu name value. func (c *amContext) SetLeftMenu(name string) { - AmSessionPut(c.session, "leftMenu", name) + c.session.Set("leftMenu", name) } /* SetLoginCookie adds the login cookie to the result output. @@ -386,18 +383,18 @@ func (c *amContext) SetScratch(name string, val any) { // GetSession returns a session variable. func (c *amContext) GetSession(name string) any { - rc, _ := AmSessionGet(c.session, "x."+name) + rc, _ := c.session.Get("x." + name) return rc } // SetSession sets a session variable. func (c *amContext) SetSession(name string, value any) { - AmSessionPut(c.session, "x."+name, value) + c.session.Set("x."+name, value) } // IsSession tests to see whether a session value is set. func (c *amContext) IsSession(name string) bool { - _, ok := AmSessionGet(c.session, "x."+name) + _, ok := c.session.Get("x." + name) return ok } @@ -427,7 +424,7 @@ func (c *amContext) VarMap() jet.VarMap { } // defoptions is the default options for the HTTP session. -var defoptions *sessions.Options = &sessions.Options{ +var defoptions *AmSessionOptions = &AmSessionOptions{ Path: "/", MaxAge: 86400, HttpOnly: true, @@ -468,17 +465,18 @@ func newContext(ctxt echo.Context) (*amContext, error) { rc.echoContext = ctxt ctxt.Set("__amsterdam_context", rc) - sess, err := session.Get("AMSTERDAM_SESSION", ctxt) + store := ctxt.Get("AmSessionStore").(AmSessionStore) + sess, err := store.Get(ctxt.Request(), "AMSTERDAM_SESSION") if err == nil { rc.session = sess - sess.Options = defoptions - if sess.IsNew { - AmSessionFirstTime(ctxt.Request().Context(), sess) + sess.SetOptions(defoptions) + if sess.IsNew() { + sess.FirstTime(ctxt.Request().Context()) } else { - AmHitSession(sess) + sess.Hit() } } - id, ok := AmSessionUid(sess) + id, ok := sess.Uid() if ok { rc.user, err = database.AmGetUser(ctxt.Request().Context(), id) if err == nil { @@ -492,7 +490,7 @@ func newContext(ctxt echo.Context) (*amContext, error) { rc.effectiveLevel = database.AmRole("NotInList").Level() } if rc.user != nil && !rc.user.IsAnon { - cp, ok := AmSessionGet(sess, "lastCommunity") + cp, ok := sess.Get("lastCommunity") if ok { rc.SetCommunityContext(fmt.Sprintf("%d", cp)) } diff --git a/ui/amsession.go b/ui/amsession.go index 3b00520..0a304a8 100644 --- a/ui/amsession.go +++ b/ui/amsession.go @@ -10,6 +10,7 @@ package ui import ( + "context" "crypto/rand" "encoding/hex" "net/http" @@ -17,8 +18,20 @@ import ( "sync" "sync/atomic" "time" + + "git.erbosoft.com/amy/amsterdam/config" + "git.erbosoft.com/amy/amsterdam/database" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" ) +/* + This is mainly a rewrite of parts of Gorilla Sessions, but with a more-defined session interface so that we can mutex-protect + the session variables, as our use case also dictates that the sessions be part of a global map in the session store so they can + be timed out as well as used to show the logged-in users. This is similar to the session support provided in J2EE servlets. +*/ + +// AmSessionOptions gives the options for the session. type AmSessionOptions struct { Path string Domain string @@ -29,6 +42,7 @@ type AmSessionOptions struct { SameSite http.SameSite } +// newCookieFromOptions creates a new HTTP cookie given the options. func newCookieFromOptions(name, value string, options *AmSessionOptions) *http.Cookie { return &http.Cookie{ Name: name, @@ -43,12 +57,14 @@ func newCookieFromOptions(name, value string, options *AmSessionOptions) *http.C } } +// AmSession is the public session interface. type AmSession interface { ID() string Name() string Save(*http.Request, http.ResponseWriter) error Store() AmSessionStore Options() *AmSessionOptions + SetOptions(*AmSessionOptions) IsNew() bool SetNew(bool) AddFlash(value any, vars ...string) @@ -56,8 +72,14 @@ type AmSession interface { Get(any) (any, bool) Set(any, any) Erase() + Uid() (int32, bool) + SetUser(*database.User) + FirstTime(context.Context) + Reset(context.Context) + Hit() } +// AmSessionStore is the public interface to the session store. type AmSessionStore interface { Get(*http.Request, string) (AmSession, error) New(*http.Request, string) (AmSession, error) @@ -65,6 +87,7 @@ type AmSessionStore interface { SessionInfo() (int, []string, int) } +// amSession is the implementation structure for AmSession. type amSession struct { mutex sync.RWMutex id string @@ -75,38 +98,51 @@ type amSession struct { name string } +// defaultFlashKey is the default sesison variable key for "flashes." const defaultFlashKey = "__flash" +// ID returns the ID of the session. func (sess *amSession) ID() string { return sess.id } +// Name returns the name of the session, used for the cookie name. func (sess *amSession) Name() string { return sess.name } +// Save is a helper function that calls the session store to save this session. func (sess *amSession) Save(r *http.Request, w http.ResponseWriter) error { return sess.store.Save(r, w, sess) } +// Store returns the pointer to the session store. func (sess *amSession) Store() AmSessionStore { return sess.store } +// Options returns the options for this session. func (sess *amSession) Options() *AmSessionOptions { return sess.options } +func (sess *amSession) SetOptions(opt *AmSessionOptions) { + sess.options = opt +} + +// IsNew returns the "new" flag of this session. func (sess *amSession) IsNew() bool { return sess.isNew } +// SetNew sets the "new" flag of this session. func (sess *amSession) SetNew(v bool) { sess.mutex.Lock() sess.isNew = v sess.mutex.Unlock() } +// AddFlash adds a "flash message" to the session. The second parameter allows optionally specifying the variable name. func (sess *amSession) AddFlash(value any, vars ...string) { key := defaultFlashKey if len(vars) > 0 { @@ -121,6 +157,7 @@ func (sess *amSession) AddFlash(value any, vars ...string) { sess.values[key] = append(flashes, value) } +// Flashes retrueves all "flash messages" from the session. The second parameter allows optionally specifying the variable name. func (sess *amSession) Flashes(vars ...string) []any { var flashes []any key := defaultFlashKey @@ -136,6 +173,7 @@ func (sess *amSession) Flashes(vars ...string) []any { return flashes } +// Get gets a session variable. func (sess *amSession) Get(key any) (any, bool) { sess.mutex.RLock() defer sess.mutex.RUnlock() @@ -143,12 +181,14 @@ func (sess *amSession) Get(key any) (any, bool) { return v, ok } +// Set sets a session variable. func (sess *amSession) Set(key, value any) { sess.mutex.Lock() defer sess.mutex.Unlock() sess.values[key] = value } +// Erase erases all session variables. func (sess *amSession) Erase() { sess.mutex.Lock() defer sess.mutex.Unlock() @@ -157,6 +197,52 @@ func (sess *amSession) Erase() { } } +// Uid returns the current user ID associated with this session. +func (sess *amSession) Uid() (int32, bool) { + if rc, ok := sess.Get("user_id"); ok { + return rc.(int32), ok + } + return -1, false +} + +// SetUser sets a user into the session, saving off the username, ID, and anonymous flag. +func (sess *amSession) SetUser(user *database.User) { + sess.mutex.Lock() + defer sess.mutex.Unlock() + sess.values["user_id"] = user.Uid + sess.values["user_name"] = user.Username + sess.values["user_anon"] = user.IsAnon +} + +// setAnon sets this session to contain the anonymous user. +func (sess *amSession) setAnon(ctx context.Context) { + u, err := database.AmGetAnonUser(ctx) + if err == nil { + sess.SetUser(u) + } else { + log.Errorf("unable to set anonymous user: %v", err) + } +} + +// FirstTime prepares the session after it was just created. +func (sess *amSession) FirstTime(ctx context.Context) { + sess.setAnon(ctx) + sess.Set("lasthit", time.Now()) +} + +// Reset resets a session after it's been timed out. +func (sess *amSession) Reset(ctx context.Context) { + sess.Erase() + sess.setAnon(ctx) + sess.Set("lasthit", time.Now()) +} + +// Hit updates the last-hit time in the session. +func (sess *amSession) Hit() { + sess.Set("lasthit", time.Now()) +} + +// amSessionStore is the implementatiuon structure for AmSessionStore. type amSessionStore struct { mutex sync.RWMutex sessions map[string]*amSession @@ -165,6 +251,7 @@ type amSessionStore struct { sweepRunning atomic.Bool } +// createAmSessionStore creates the session store. func createAmSessionStore(exp time.Duration) *amSessionStore { rc := &amSessionStore{ sessions: make(map[string]*amSession), @@ -175,6 +262,7 @@ func createAmSessionStore(exp time.Duration) *amSessionStore { return rc } +// Get retrieves a session from the request cookie. func (st *amSessionStore) Get(r *http.Request, name string) (AmSession, error) { cookie, err := r.Cookie(name) if err == nil { @@ -191,6 +279,7 @@ func (st *amSessionStore) Get(r *http.Request, name string) (AmSession, error) { return st.New(r, name) } +// New creates a new session. func (st *amSessionStore) New(r *http.Request, name string) (AmSession, error) { session := &amSession{ values: make(map[any]any), @@ -213,6 +302,7 @@ func (st *amSessionStore) New(r *http.Request, name string) (AmSession, error) { return session, nil } +// Save saves the session identifier to the response cookies. func (st *amSessionStore) Save(r *http.Request, w http.ResponseWriter, sess AmSession) error { cookie := newCookieFromOptions(sess.Name(), sess.ID(), sess.Options()) if sess.Options().MaxAge > 0 { @@ -225,6 +315,7 @@ func (st *amSessionStore) Save(r *http.Request, w http.ResponseWriter, sess AmSe return nil } +// SessionInfo returns the number of anonymous sessions, all the session user names, and the current maximum number of sessions. func (st *amSessionStore) SessionInfo() (int, []string, int) { anons := 0 users := make([]string, 0, len(st.sessions)) @@ -278,3 +369,51 @@ func (st *amSessionStore) sweep(tick <-chan time.Time, done chan bool) { } done <- true } + +// sessionStore is the global session store. +var sessionStore *amSessionStore + +// SetupAmSessionManager sets up the session store and its sweeper goroutine. +func SetupAmSessionManager() func() { + // get the time for the session to expire + d, err := time.ParseDuration(config.GlobalConfig.Site.SessionExpire) + if err != nil { + d, err = time.ParseDuration("1h") + if err != nil { + panic(err.Error()) + } + } + + // create session store + sessionStore = createAmSessionStore(d) + + // get the clock value to run sweeps + d, err = time.ParseDuration("1s") + if err != nil { + panic(err.Error()) + } + + // set up the sweep runner + tkr := time.NewTicker(d) + done := make(chan bool) + go sessionStore.sweep(tkr.C, done) + return func() { + // stop the sweep runner + sessionStore.sweepRunning.Store(false) + <-done + tkr.Stop() + } +} + +// SessionStoreInjector is middleware that injects the session store into the context variables. +func SessionStoreInjector(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set("AmSessionStore", sessionStore) + return next(c) + } +} + +// AmSessions returns the information about all current sessions. +func AmSessions() (int, []string, int) { + return sessionStore.SessionInfo() +} diff --git a/ui/session_mgr.go b/ui/session_mgr.go deleted file mode 100644 index 6913349..0000000 --- a/ui/session_mgr.go +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Amsterdam Web Communities System - * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -// Package ui holds the support for the Amsterdam user interface, wrapping Echo and Jet templates. -package ui - -import ( - "context" - "crypto/rand" - "encoding/gob" - "encoding/hex" - "net/http" - "slices" - "sync" - "sync/atomic" - "time" - - "git.erbosoft.com/amy/amsterdam/config" - "git.erbosoft.com/amy/amsterdam/database" - "github.com/gorilla/sessions" - log "github.com/sirupsen/logrus" -) - -func AmSessionGet(sess *sessions.Session, key any) (any, bool) { - if sess == nil { - return 0, false - } - mtx := sess.Values["_mutex"].(*sync.RWMutex) - mtx.RLock() - defer mtx.RUnlock() - rc, ok := sess.Values[key] - return rc, ok -} - -func AmSessionPut(sess *sessions.Session, key, value any) { - if sess != nil { - mtx := sess.Values["_mutex"].(*sync.RWMutex) - mtx.Lock() - defer mtx.Unlock() - sess.Values[key] = value - } -} - -func AmSessionErase(sess *sessions.Session) { - if sess != nil { - mtx := sess.Values["_mutex"].(*sync.RWMutex) - mtx.Lock() - defer mtx.Unlock() - for k := range sess.Values { - if k != "_mutex" { - delete(sess.Values, k) - } - } - } -} - -// AmsterdamStore is our implewmentation of the Gorilla session store that works close to HttpSession in Java. -type AmsterdamStore struct { - mutex sync.RWMutex - sessions map[string]*sessions.Session - maxEntries int - expiry time.Duration - sweepRunning atomic.Bool -} - -func createAmsterdamStore(exp time.Duration) *AmsterdamStore { - rc := AmsterdamStore{ - sessions: make(map[string]*sessions.Session), - maxEntries: 0, - expiry: exp, - } - rc.sweepRunning.Store(true) - return &rc -} - -/* Get (from Store interface) retrieves a new or existing session for the request. - * Parameters: - * r - The HTTP request object. - * name - The name of the session. - * Returns: - * Session pointer (new or existing) - * Standard Go error status. - */ -func (st *AmsterdamStore) Get(r *http.Request, name string) (*sessions.Session, error) { - cookie, err := r.Cookie(name) - if err == nil { - st.mutex.RLock() - session, ok := st.sessions[cookie.Value] - if ok { - session.IsNew = false - } - st.mutex.RUnlock() - if ok { - return session, nil - } - } - return st.New(r, name) -} - -/* New (from Store interface) creates and returns a new session object. - * Parameters: - * r - The HTTP request object. - * name - The name of the session. - * Returns: - * New session pointer - * Standard Go error status. - */ -func (st *AmsterdamStore) New(r *http.Request, name string) (*sessions.Session, error) { - session := sessions.NewSession(st, name) - session.IsNew = true - session.Values["_mutex"] = new(sync.RWMutex) - idBytes := make([]byte, 32) - if _, err := rand.Read(idBytes); err != nil { - return nil, err - } - session.ID = hex.EncodeToString(idBytes) - st.mutex.Lock() - st.sessions[session.ID] = session - if len(st.sessions) > st.maxEntries { - st.maxEntries = len(st.sessions) - } - st.mutex.Unlock() - return session, nil -} - -/* Save (from Store interface) saves off the sessin information to the response. - * Parameters: - * r - The HTTP request object. - * w - The response writer object. - * session - The session pointer to be saved. - * Returns: - * Standard Go error status. - */ -func (st *AmsterdamStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error { - cookie := sessions.NewCookie(session.Name(), session.ID, session.Options) - http.SetCookie(w, cookie) - return nil -} - -/* sweep sweeps sessions to remove expired ones. - * Parameters: - * tick - Channel that "pulses" periodically to run the task. - * done - Channel we write to when we're done. - */ -func (st *AmsterdamStore) sweep(tick <-chan time.Time, done chan bool) { - for range tick { - if st.sweepRunning.Load() { - // phase 1 - identify expired sessions - st.mutex.RLock() - zap := make([]string, 0, len(st.sessions)) - for k, v := range st.sessions { - lastTime, ok := AmSessionGet(v, "lasthit") - if ok && time.Since(lastTime.(time.Time)) > st.expiry { - zap = append(zap, k) - } - } - st.mutex.RUnlock() - - // phase 2 - get rid of the expired sessions - for _, k := range zap { - st.mutex.Lock() - s, ok := st.sessions[k] - if ok { - delete(st.sessions, k) - AmSessionErase(s) - } - st.mutex.Unlock() - } - } else { - break - } - } - done <- true -} - -// sessioninfo returns information about the sessions in the store. -func (st *AmsterdamStore) sessionInfo() (int, []string, int) { - anons := 0 - users := make([]string, 0, len(st.sessions)) - st.mutex.RLock() - for _, s := range st.sessions { - v, ok := AmSessionGet(s, "user_anon") - if ok && v.(bool) { - anons++ - } else { - name, _ := AmSessionGet(s, "user_name") - users = append(users, name.(string)) - } - } - st.mutex.RUnlock() - slices.Sort(users) - return anons, users, st.maxEntries -} - -// SessionStore is the Gorilla session store used by Amsterdam. -var SessionStore *AmsterdamStore - -// init registers the time.Time value to be gobbed. -func init() { - gob.Register(time.Time{}) -} - -// SetupSessionManager sets up the session manager. -func SetupSessionManager() func() { - // get the time for the session to expire - d, err := time.ParseDuration(config.GlobalConfig.Site.SessionExpire) - if err != nil { - d, err = time.ParseDuration("1h") - if err != nil { - panic(err.Error()) - } - } - - // create session store - SessionStore = createAmsterdamStore(d) - - // get the clock value to run sweeps - d, err = time.ParseDuration("1s") - if err != nil { - panic(err.Error()) - } - - // set up the sweep runner - tkr := time.NewTicker(d) - done := make(chan bool) - go SessionStore.sweep(tkr.C, done) - return func() { - // stop the sweep runner - SessionStore.sweepRunning.Store(false) - <-done - tkr.Stop() - } -} - -// AmSessionUid returns the current user ID of the session. -func AmSessionUid(session *sessions.Session) (int32, bool) { - rc, ok := AmSessionGet(session, "user_id") - if ok { - return rc.(int32), ok - } else { - return -1, ok - } -} - -/* AmSetSessionUser sets the user for the session. - * Parameters: - * session - The session to be updated. - * user - The user to be associated with the session. - */ -func AmSetSessionUser(session *sessions.Session, user *database.User) { - AmSessionPut(session, "user_id", user.Uid) - AmSessionPut(session, "user_name", user.Username) - AmSessionPut(session, "user_anon", user.IsAnon) -} - -// setSessionAnon sets the user for the session to the anonymous user. -func setSessionAnon(ctx context.Context, session *sessions.Session) { - u, err := database.AmGetAnonUser(ctx) - if err == nil { - AmSetSessionUser(session, u) - } else { - log.Errorf("unable to set anonymous user: %v", err) - } -} - -var lastHitMutex sync.Mutex - -// AmSessionFirstTime initializes the session after it's first created. -func AmSessionFirstTime(ctx context.Context, session *sessions.Session) { - lastHitMutex.Lock() - setSessionAnon(ctx, session) - AmSessionPut(session, "lasthit", time.Now()) - lastHitMutex.Unlock() -} - -// AmResetSession clears the specified session. -func AmResetSession(ctx context.Context, session *sessions.Session) { - lastHitMutex.Lock() - AmSessionErase(session) - setSessionAnon(ctx, session) - AmSessionPut(session, "lasthit", time.Now()) - lastHitMutex.Unlock() -} - -// AmHitSession "hits" a session, updating its "last hit" time. -func AmHitSession(session *sessions.Session) { - lastHitMutex.Lock() - AmSessionPut(session, "lasthit", time.Now()) - lastHitMutex.Unlock() -} - -/* AmSessions returns information about the currently active sessions. - * Returns: - * Number of users active but not logged in - * List of user names currently logged in - * Maximum number of users ever in session table. - */ -func AmSessions() (int, []string, int) { - return SessionStore.sessionInfo() -}