diff --git a/config/config.go b/config/config.go index e6d0c2e..03e8b67 100644 --- a/config/config.go +++ b/config/config.go @@ -52,6 +52,7 @@ type AmConfig struct { TopRefresh int `yaml:"topRefresh"` LoginCookieName string `yaml:"loginCookieName"` LoginCookieAge int `yaml:"loginCookieAge"` + SessionExpire string `yaml:"sessionExpire"` UserAgreement struct { Title string `yaml:"title"` Text string `yaml:"text"` @@ -138,6 +139,7 @@ func overlayConfig(dest *AmConfig, loaded *AmConfig, defaults *AmConfig) { dest.Site.TopRefresh = overlayInt(loaded.Site.TopRefresh, defaults.Site.TopRefresh) dest.Site.LoginCookieName = overlayString(loaded.Site.LoginCookieName, defaults.Site.LoginCookieName) dest.Site.LoginCookieAge = overlayInt(loaded.Site.LoginCookieAge, defaults.Site.LoginCookieAge) + dest.Site.SessionExpire = overlayString(loaded.Site.SessionExpire, defaults.Site.SessionExpire) dest.Site.UserAgreement.Title = overlayString(loaded.Site.UserAgreement.Title, defaults.Site.UserAgreement.Title) dest.Site.UserAgreement.Text = overlayString(loaded.Site.UserAgreement.Text, defaults.Site.UserAgreement.Text) dest.Database.Driver = overlayString(loaded.Database.Driver, defaults.Database.Driver) diff --git a/config/default.yaml b/config/default.yaml index 16c1d39..b830c00 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -12,6 +12,7 @@ site: topRefresh: 300 loginCookieName: AmsterdamAuth loginCookieAge: 365 + sessionExpire: "3h" userAgreement: title: "Amsterdam User Agreement" text: > diff --git a/go.mod b/go.mod index b807fbd..596375f 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,17 @@ go 1.25.0 require ( github.com/CloudyKit/jet/v6 v6.3.1 + github.com/alexflint/go-arg v1.6.0 + github.com/biter777/countries v1.7.5 + github.com/go-sql-driver/mysql v1.9.3 + github.com/google/uuid v1.6.0 + github.com/gorilla/sessions v1.4.0 + github.com/hashicorp/golang-lru v1.0.2 + github.com/jmoiron/sqlx v1.4.0 + github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.4 github.com/labstack/gommon v0.4.2 + github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b github.com/sirupsen/logrus v1.9.3 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,19 +22,11 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect - github.com/alexflint/go-arg v1.6.0 github.com/alexflint/go-scalar v1.2.0 // indirect - github.com/biter777/countries v1.7.5 - github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/sessions v1.4.0 - github.com/hashicorp/golang-lru v1.0.2 - github.com/jmoiron/sqlx v1.4.0 - github.com/labstack/echo-contrib v0.17.4 github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.38.0 // indirect diff --git a/go.sum b/go.sum index ed05acd..2625a33 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= diff --git a/main.go b/main.go index 22a7eaf..cf3e351 100644 --- a/main.go +++ b/main.go @@ -68,7 +68,8 @@ func main() { closer = email.SetupMailSender() defer closer() ui.SetupTemplates() - ui.SetupSessionManager() + closer = ui.SetupSessionManager() + defer closer() ui.SetupLeftMenus() // Set up Echo. diff --git a/top.go b/top.go index 6cbc0f0..c48d11d 100644 --- a/top.go +++ b/top.go @@ -55,8 +55,8 @@ func buildCommunitiesSidebox(uid int32, out *RenderedSidebox, in *database.Sideb out.Items = make([]RenderedSideboxItem, len(l)) for i, c := range l { out.Items[i].Text = c.Name - out.Items[i].Link = new(string) - *out.Items[i].Link = "/TODO/community/" + c.Alias + lk := fmt.Sprintf("/TODO/community/%s", c.Alias) + out.Items[i].Link = &lk out.Items[i].Flags = make(map[string]bool) var level uint16 level, err = database.AmGetCommunityAccessLevel(uid, c.Id) @@ -67,6 +67,7 @@ func buildCommunitiesSidebox(uid int32, out *RenderedSidebox, in *database.Sideb out.TemplateName = "sb_ftrcomm.jet" } } + _ = in return err } @@ -92,7 +93,33 @@ func buildFeaturedConferences(uid int32, out *RenderedSidebox, in *database.Side * Standard Go error status. */ func buildUsersOnline(uid int32, out *RenderedSidebox, in *database.Sidebox) error { + out.Title = "Users Online" out.TemplateName = "sb_online.jet" + anons, users, maxUsers := ui.AmSessions() + cap := len(users) + 1 + if anons > 0 { + cap++ + } + out.Items = make([]RenderedSideboxItem, cap) + out.Items[0].Text = fmt.Sprintf("%d total (max %d)", len(users)+anons, maxUsers) + out.Items[0].Flags = make(map[string]bool) + out.Items[0].Flags["nobullet"] = true + out.Items[0].Flags["bold"] = true + b := 1 + if anons > 0 { + out.Items[1].Text = fmt.Sprintf("Not logged in (%d)", anons) + out.Items[1].Flags = make(map[string]bool) + b++ + } + for i, n := range users { + out.Items[b+i].Text = n + lk := fmt.Sprintf("/TODO/user/%s", n) + out.Items[b+i].Link = &lk + out.Items[b+i].Flags = make(map[string]bool) + out.Items[b+i].Flags["bold"] = true + } + _ = uid + _ = in return nil } diff --git a/ui/amcontext.go b/ui/amcontext.go index 569b01a..d42e4d3 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -75,15 +75,12 @@ func (c *amContext) ClearLoginCookie() { // ClearSession clears the current session. func (c *amContext) ClearSession() { - for k := range c.session.Values { - delete(c.session.Values, k) - } - setupAmSession(c.session) + AmResetSession(c.session) } // CurrentUser returns the current user from the session. func (c *amContext) CurrentUser() *database.User { - u, err := database.AmGetUser(c.session.Values["user_id"].(int32)) + u, err := database.AmGetUser(AmSessionUid(c.session)) if err != nil { log.Errorf("unable to retrieve current user") } @@ -92,7 +89,7 @@ func (c *amContext) CurrentUser() *database.User { // CurrentUserId returns the current user ID. func (c *amContext) CurrentUserId() int32 { - return c.session.Values["user_id"].(int32) + return AmSessionUid(c.session) } /* FormField returns the value of a form field from the request. @@ -174,7 +171,7 @@ func (c *amContext) Render(name string) error { * u - New user to associate with the context. */ func (c *amContext) ReplaceUser(u *database.User) { - c.session.Values["user_id"] = u.Uid + AmSetSessionUser(c.session, u) } // SaveSession saves the session link to cookies. @@ -295,7 +292,9 @@ func NewAmContext(ctxt echo.Context) (AmContext, error) { rc.session = sess sess.Options = defoptions if sess.IsNew { - setupAmSession(sess) + AmSessionFirstTime(sess) + } else { + AmHitSession(sess) } } return &rc, err diff --git a/ui/session_mgr.go b/ui/session_mgr.go index ba22941..f7820f9 100644 --- a/ui/session_mgr.go +++ b/ui/session_mgr.go @@ -11,8 +11,15 @@ package ui import ( + "encoding/gob" + "slices" + "sync" + "sync/atomic" + "time" + "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" + "github.com/google/uuid" "github.com/gorilla/sessions" "github.com/quasoft/memstore" log "github.com/sirupsen/logrus" @@ -21,17 +28,169 @@ import ( // SessionStore is the Gorilla session store used by Amsterdam. var SessionStore sessions.Store -// SetupSessionManager sets up the session manager. -func SetupSessionManager() { - SessionStore = memstore.NewMemStore([]byte(config.GlobalConfig.Rendering.CookieKey)) +// sessionTable is the global map of all sessions. +var sessionTable map[string]*sessions.Session + +// sessionTableMax is the maximum number of entries in the session table. +var sessionTableMax int = 0 + +// sessionTableMutex is the mutex for the session table. +var sessionTableMutex sync.RWMutex + +// sessionExpiry is the amount of time before a session expires. +var sessionExpiry time.Duration + +// sweepRunning is the running flag for session sweeping. +var sweepRunning atomic.Bool + +// sweepSessions sweeps through the sessions table and removes any expired sessions. +func sweepSessions(tick <-chan time.Time, done chan bool) { + for range tick { + if sweepRunning.Load() { + // phase 1 - identify expired sessions + sessionTableMutex.RLock() + zap := make([]string, 0, len(sessionTable)) + for k, v := range sessionTable { + lastTime := v.Values["lasthit"].(time.Time) + if time.Since(lastTime) > sessionExpiry { + zap = append(zap, k) + } + } + sessionTableMutex.RUnlock() + + // phase 2 - get rid of the expired sessions + for _, k := range zap { + sessionTableMutex.Lock() + s := sessionTable[k] + delete(sessionTable, k) + sessionTableMutex.Unlock() + for q := range s.Values { + delete(s.Values, q) + } + } + } else { + break + } + } + done <- true } -// setupAmSession sets up a newly created Amsterdam session. -func setupAmSession(session *sessions.Session) { - u, err := database.AmGetAnonUser() - if err == nil { - session.Values["user_id"] = u.Uid - } else { - log.Errorf("Unable to load anon user: %v", err) +// init registers the time.Time value to be gobbed. +func init() { + gob.Register(time.Time{}) +} + +// SetupSessionManager sets up the session manager. +func SetupSessionManager() func() { + // create session store + SessionStore = memstore.NewMemStore([]byte(config.GlobalConfig.Rendering.CookieKey)) + + // create session table + sessionTable = make(map[string]*sessions.Session) + + // 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()) + } + } + sessionExpiry = d + + // get the clock value to run sweeps + d, err = time.ParseDuration("1s") + if err != nil { + panic(err.Error()) + } + + // set up the sweep runner + sweepRunning.Store(true) + tkr := time.NewTicker(d) + done := make(chan bool) + go sweepSessions(tkr.C, done) + return func() { + // stop the sweep runner + sweepRunning.Store(false) + <-done + tkr.Stop() } } + +// AmSessionUid returns the current user ID of the session. +func AmSessionUid(session *sessions.Session) int32 { + return session.Values["user_id"].(int32) +} + +/* 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) { + session.Values["user_id"] = user.Uid + session.Values["user_name"] = user.Username + session.Values["user_anon"] = user.IsAnon +} + +// setSessionAnon sets the user for the session to the anonymous user. +func setSessionAnon(session *sessions.Session) { + u, err := database.AmGetAnonUser() + if err == nil { + AmSetSessionUser(session, u) + } else { + log.Errorf("unable to set anonymous user: %v", err) + } +} + +// AmSessionFirstTime initializes the session after it's first created. +func AmSessionFirstTime(session *sessions.Session) { + key := uuid.NewString() + session.Values["key"] = key + setSessionAnon(session) + sessionTableMutex.Lock() + sessionTable[key] = session + if len(sessionTable) > sessionTableMax { + sessionTableMax = len(sessionTable) + } + session.Values["lasthit"] = time.Now() + sessionTableMutex.Unlock() +} + +// AmResetSession clears the specified session. +func AmResetSession(session *sessions.Session) { + key := session.Values["key"] + for k := range session.Values { + delete(session.Values, k) + } + session.Values["key"] = key + setSessionAnon(session) + session.Values["lasthit"] = time.Now() +} + +// AmHitSession "hits" a session, updating its "last hit" time. +func AmHitSession(session *sessions.Session) { + session.Values["lasthit"] = time.Now() +} + +/* 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) { + anons := 0 + users := make([]string, 0, len(sessionTable)) + sessionTableMutex.RLock() + for _, s := range sessionTable { + if s.Values["user_anon"].(bool) { + anons++ + } else { + users = append(users, s.Values["user_name"].(string)) + } + } + sessionTableMutex.RUnlock() + slices.Sort(users) + return anons, users, sessionTableMax +} diff --git a/ui/views/sb_online.jet b/ui/views/sb_online.jet index c4798bb..fa47a07 100644 --- a/ui/views/sb_online.jet +++ b/ui/views/sb_online.jet @@ -7,15 +7,35 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. *} +{{ sb := .GetScratch("__sidebox") }}