420 lines
11 KiB
Go
420 lines
11 KiB
Go
/*
|
|
* Amsterdam Web Communities System
|
|
* Copyright (c) 2025-2026 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/.
|
|
*
|
|
* SPDX-License-Identifier: 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/hex"
|
|
"net/http"
|
|
"slices"
|
|
"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
|
|
MaxAge int
|
|
Secure bool
|
|
HttpOnly bool
|
|
Partitioned bool
|
|
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,
|
|
Value: value,
|
|
Path: options.Path,
|
|
Domain: options.Domain,
|
|
MaxAge: options.MaxAge,
|
|
Secure: options.Secure,
|
|
HttpOnly: options.HttpOnly,
|
|
Partitioned: options.Partitioned,
|
|
SameSite: options.SameSite,
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
Flashes(vars ...string) []any
|
|
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)
|
|
Save(*http.Request, http.ResponseWriter, AmSession) error
|
|
SessionInfo() (int, []string, int)
|
|
}
|
|
|
|
// amSession is the implementation structure for AmSession.
|
|
type amSession struct {
|
|
mutex sync.RWMutex
|
|
id string
|
|
values map[any]any
|
|
options *AmSessionOptions
|
|
isNew bool
|
|
store AmSessionStore
|
|
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 {
|
|
key = vars[0]
|
|
}
|
|
var flashes []any
|
|
sess.mutex.Lock()
|
|
defer sess.mutex.Unlock()
|
|
if v, ok := sess.values[key]; ok {
|
|
flashes = v.([]any)
|
|
}
|
|
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
|
|
if len(vars) > 0 {
|
|
key = vars[0]
|
|
}
|
|
sess.mutex.Lock()
|
|
defer sess.mutex.Unlock()
|
|
if v, ok := sess.values[key]; ok {
|
|
delete(sess.values, key)
|
|
flashes = v.([]any)
|
|
}
|
|
return flashes
|
|
}
|
|
|
|
// Get gets a session variable.
|
|
func (sess *amSession) Get(key any) (any, bool) {
|
|
sess.mutex.RLock()
|
|
defer sess.mutex.RUnlock()
|
|
v, ok := sess.values[key]
|
|
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()
|
|
clear(sess.values)
|
|
}
|
|
|
|
// 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
|
|
maxEntries int
|
|
expiry time.Duration
|
|
sweepRunning atomic.Bool
|
|
}
|
|
|
|
// createAmSessionStore creates the session store.
|
|
func createAmSessionStore(exp time.Duration) *amSessionStore {
|
|
rc := &amSessionStore{
|
|
sessions: make(map[string]*amSession),
|
|
maxEntries: 0,
|
|
expiry: exp,
|
|
}
|
|
rc.sweepRunning.Store(true)
|
|
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 {
|
|
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 creates a new session.
|
|
func (st *amSessionStore) New(r *http.Request, name string) (AmSession, error) {
|
|
session := &amSession{
|
|
values: make(map[any]any),
|
|
options: new(AmSessionOptions),
|
|
isNew: true,
|
|
store: st,
|
|
name: name,
|
|
}
|
|
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 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 {
|
|
d := time.Duration(sess.Options().MaxAge) * time.Second
|
|
cookie.Expires = time.Now().Add(d)
|
|
} else if sess.Options().MaxAge < 0 {
|
|
cookie.Expires = time.Unix(1, 0)
|
|
}
|
|
http.SetCookie(w, cookie)
|
|
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))
|
|
st.mutex.RLock()
|
|
for _, s := range st.sessions {
|
|
v, ok := s.Get("user_anon")
|
|
if ok && v.(bool) {
|
|
anons++
|
|
} else {
|
|
name, _ := s.Get("user_name")
|
|
users = append(users, name.(string))
|
|
}
|
|
}
|
|
st.mutex.RUnlock()
|
|
slices.Sort(users)
|
|
return anons, users, st.maxEntries
|
|
}
|
|
|
|
/* 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 *amSessionStore) 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 := v.Get("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)
|
|
s.Erase()
|
|
}
|
|
st.mutex.Unlock()
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
done <- true
|
|
}
|
|
|
|
// sessionStore is the global session store.
|
|
var sessionStore *amSessionStore
|
|
|
|
// setupSessionManager sets up the session store and its sweeper goroutine.
|
|
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 = 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()
|
|
}
|