This commit is contained in:
DarkGopher 2025-05-31 17:12:11 +02:00
parent 09632be46c
commit 1c3000e532
7 changed files with 569 additions and 0 deletions

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 DarkGopher
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

5
files/file.go Normal file
View file

@ -0,0 +1,5 @@
// Package file implements sessions saved into files which contains gob data
package file
// SessionFile implement manager.Session interface
type SessionFile struct{}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module git.mtux.eu/darkgopher/session
go 1.23.4

161
session.go Normal file
View file

@ -0,0 +1,161 @@
// Package session (session manager) control process session lifetime create, update and destroy
package session
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
var milis int64 = 1000
// for provider fun
var provides = make(map[string]Provider)
// ProviderNames return slice of strings - registered Provider names
func ProviderNames() []string {
var prdnames []string
for pn := range provides {
prdnames = append(prdnames, pn)
}
return prdnames
}
// MilisPerSec return time resolution (milliseconds / 1sec)
func MilisPerSec() int64 {
return milis
}
// Session interface implement storage for one session and have maxLifetime and lastAccessTime
type Session interface {
//set session value and update last access time
Set(key, value interface{}) error
//get session value and update last access time
Get(key interface{}) (v any, err error)
//delete session value
Delete(key interface{}) error
//get session id for session
SessionID() string
}
// Provider interace implement lifecycle for one session
type Provider interface {
//create new session using sid value
Init(sid string) (Session, error)
//read return existing session by id or if not exist create new session
Read(sid string) (Session, error)
//destroy remove session with sid from storage if exist
Destroy(sid string) error
//regenerate id change old sid to newsid and preserve existing session data
RegenerateID(oldsid, newsid string) (err error)
//gc remove all sessions with > maxLifetime
GC(maxlifetime int64)
}
// Register makes a session provide available by the provided name.
// If Register is called twice with the same name or if driver is nil, it panics.
func Register(name string, provide Provider) {
if provide == nil {
panic("session: Register provide is nil, must be import any session storage implementation")
}
if _, dup := provides[name]; dup {
panic("session: Already registered provider: " + name)
}
provides[name] = provide
}
// Manager controls all sessions with registered storage provider
type Manager struct {
cookieName string
provider Provider
maxlifetime int64
secure bool
}
// NewManager create new *Manager for provideName, cookieName and maxlifetime in seconds
func NewManager(providerName, cookieName string, maxlifetime int64, ssl bool) (*Manager, error) {
var provider Provider
var ok bool
if provider, ok = provides[providerName]; !ok {
return nil, fmt.Errorf("session: Provider: %q not found (forgotten import?)", providerName)
}
m := &Manager{
cookieName: cookieName, provider: provider,
maxlifetime: maxlifetime, secure: ssl,
}
m.GC()
return m, nil
}
// generate new secure 32 byte sessionID
func (manager *Manager) sessionID() (sid string, err error) {
b := make([]byte, 32)
if _, err = io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("Session manager error: generate sessionID failed: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
// SessionStart start session for next http response
func (manager *Manager) SessionStart(w http.ResponseWriter, r *http.Request) (session Session, err error) {
var cookie *http.Cookie
var sid string
if cookie, err = r.Cookie(manager.cookieName); err != nil || cookie == nil {
if sid, err = manager.sessionID(); err != nil {
return nil, err
}
if session, err = manager.provider.Init(sid); err != nil {
return nil, fmt.Errorf("Session init failed: %w", err)
}
cookie := http.Cookie{
Name: manager.cookieName, Value: url.QueryEscape(sid), Path: "/",
HttpOnly: true, MaxAge: int(manager.maxlifetime),
Secure: manager.secure,
}
http.SetCookie(w, &cookie)
} else {
if sid, err = url.QueryUnescape(cookie.Value); err != nil {
return nil, fmt.Errorf("Session cookie decode error: %v", err)
}
if session, err = manager.provider.Read(sid); err != nil {
return nil, fmt.Errorf("Session provider read error: %w", err)
}
}
return
}
// SessionDestroy end session and delete session data at the server
func (manager *Manager) SessionDestroy(w http.ResponseWriter, r *http.Request) (err error) {
var cookie *http.Cookie
if cookie, err = r.Cookie(manager.cookieName); err != nil || cookie.Value == "" {
return
}
manager.provider.Destroy(cookie.Value)
rmcookie := http.Cookie{
Name: manager.cookieName, Path: "/", HttpOnly: true,
Expires: time.Now(), MaxAge: -1,
}
http.SetCookie(w, &rmcookie)
return nil
}
// RegenerateID vhange sid and preserve all session data
func (manager *Manager) RegenerateID(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie(manager.cookieName); err == nil && ck.Value != "" {
if newid, err := manager.sessionID(); err != nil {
manager.provider.RegenerateID(ck.Value, newid)
}
}
}
// GC remove sessions which exceeded manager.maxLifetime
func (manager *Manager) GC() {
manager.provider.GC(manager.maxlifetime)
msec := milis * manager.maxlifetime
time.AfterFunc(time.Duration(msec), func() { manager.GC() })
}

5
session_test.go Normal file
View file

@ -0,0 +1,5 @@
package session
func init() {
milis = 1
}

137
storage/memory/memory.go Normal file
View file

@ -0,0 +1,137 @@
// Package memory implements sessions saved into memory is gone when stop the server
package memory
import (
"container/list"
"sync"
"time"
"git.mtux.eu/darkgopher/session"
)
var pder = &ProviderMemory{list: list.New()}
func init() {
pder.sessions = make(map[string]*list.Element, 0)
session.Register("memory", pder)
}
// SessionMemory implement sessionma.Session interface only in memory
type SessionMemory struct {
sid string
atime time.Time
data map[any]any
}
func (sm *SessionMemory) resolvepanic(err *error) {
if r := recover(); r != nil {
*err = r.(error)
}
}
// Set -
func (sm *SessionMemory) Set(k any, v any) (err error) {
defer pder.updateAtime(sm.sid)
defer sm.resolvepanic(&err)
sm.data[k] = v
return
}
// Get -
func (sm *SessionMemory) Get(k any) (v any, err error) {
defer pder.updateAtime(sm.sid)
defer sm.resolvepanic(&err)
return sm.data[k], err
}
// Delete -
func (sm *SessionMemory) Delete(k any) (err error) {
defer pder.updateAtime(sm.sid)
delete(sm.data, k)
return
}
// SessionID -
func (sm *SessionMemory) SessionID() string {
return sm.sid
}
// ProviderMemory implement memory session provider
type ProviderMemory struct {
lock sync.Mutex
sessions map[string]*list.Element //save to RAM
list *list.List //for GC
}
func (pder *ProviderMemory) updateAtime(sid string) {
pder.lock.Lock()
defer pder.lock.Unlock()
if ssel, ok := pder.sessions[sid]; ok {
ssel.Value.(*SessionMemory).atime = time.Now()
pder.list.MoveToFront(ssel)
}
}
// Init create new session store for sid
func (pder *ProviderMemory) Init(sid string) (ses session.Session, err error) {
pder.lock.Lock()
defer pder.lock.Unlock()
data := make(map[any]any, 0)
sess := &SessionMemory{sid, time.Now(), data}
sessel := pder.list.PushBack(sess)
pder.sessions[sid] = sessel
return sess, nil
}
// read return existing unexpired session or create new
func (pder *ProviderMemory) Read(sid string) (ses session.Session, err error) {
pder.lock.Lock()
defer pder.lock.Unlock()
if ssel, ok := pder.sessions[sid]; ok {
return ssel.Value.(*SessionMemory), nil
}
return pder.Init(sid)
}
// Destroy delete session storage with sid
func (pder *ProviderMemory) Destroy(sid string) (err error) {
pder.lock.Lock()
defer pder.lock.Unlock()
if ssel, ok := pder.sessions[sid]; ok {
delete(pder.sessions, sid)
pder.list.Remove(ssel)
return nil
}
return
}
// RegenerateID replace session ID to new one and preserve all data
func (pder *ProviderMemory) RegenerateID(oldsid, newsid string) (err error) {
pder.lock.Lock()
defer pder.lock.Unlock()
if ssel, ok := pder.sessions[oldsid]; ok {
ssel.Value.(*SessionMemory).sid = newsid
delete(pder.sessions, oldsid)
pder.sessions[newsid] = ssel
}
return
}
// GC iterate all sessions and delete expired
func (pder *ProviderMemory) GC(maxlifetime int64) {
pder.lock.Lock()
defer pder.lock.Unlock()
for {
ssel := pder.list.Back()
if ssel == nil {
break
}
if ssel.Value.(*SessionMemory).atime.UnixMilli()+(maxlifetime*session.MilisPerSec()) < time.Now().UnixMilli() {
pder.list.Remove(ssel)
delete(pder.sessions, ssel.Value.(*SessionMemory).sid)
} else {
break
}
}
}

249
tests/t_test.go Normal file
View file

@ -0,0 +1,249 @@
package tests
// Package tests --
// 1) StartSession -----
// 2) DestroySessioon -----
// 3) Set session variable--|
// 4) Get session variable--|
// 5) Update -- // --
// 6) Delete -- // --
// 7) GC session after lifetime
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"testing"
"time"
"git.mtux.eu/darkgopher/session"
//all impls imports here ...
_ "git.mtux.eu/darkgopher/session/storage/memory"
)
const sid = "sid"
const lifet = 180
const testsrvaddr = ":8888"
var sm *session.Manager //current session impl.
var tc *TestClient //current test client
type TestServer struct {
addr string
}
func NewTestServer(addr string) (ts *TestServer) {
return &TestServer{addr}
}
func (ts *TestServer) Serve() {
http.HandleFunc("/startsession", ts.startSession) //ck
http.HandleFunc("/destroysession", ts.destroySession) //ck
http.HandleFunc("/setsessionvar", ts.setSessionVar)
http.HandleFunc("/getsessionvar", ts.getSessionVar)
http.HandleFunc("/updatesessionvar", ts.updateSessionVar)
http.HandleFunc("/validateupdatedvalue", ts.validateUpdatedValue)
http.HandleFunc("/deletesessionvalue", ts.deleteSessionValue)
http.HandleFunc("/validatedeletedvalue", ts.validateDeletedValue)
http.HandleFunc("/sessionGC", ts.sessionGC) //ck
http.ListenAndServe(ts.addr, nil)
}
func (ts *TestServer) startSession(w http.ResponseWriter, r *http.Request) {
var err error
if _, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
io.WriteString(w, "OK")
}
func (ts *TestServer) destroySession(w http.ResponseWriter, r *http.Request) {
var err error
if _, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
if err = sm.SessionDestroy(w, r); err != nil {
io.WriteString(w, err.Error())
}
io.WriteString(w, "OK")
}
func (ts *TestServer) setSessionVar(w http.ResponseWriter, r *http.Request) {
var err error
var sess session.Session
if sess, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
if err = sess.Set("Pes", "Minule"); err != nil {
io.WriteString(w, err.Error())
}
if err = sess.Set("Vek", 16); err != nil {
io.WriteString(w, err.Error())
}
io.WriteString(w, "OK")
}
func (ts *TestServer) getSessionVar(w http.ResponseWriter, r *http.Request) {
var err error
var sess session.Session
if sess, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
var (
v any
n any
)
if v, err = sess.Get("Pes"); err != nil {
io.WriteString(w, err.Error())
}
if n, err = sess.Get("Vek"); err != nil {
io.WriteString(w, err.Error())
}
if v.(string) != "Minule" {
fmt.Fprintf(w, "Value of session key: 'Pes' must be 'Minule', not: %s", v)
}
if n.(int) != 10 {
fmt.Fprintf(w, "Value of session key: 'Vek' must be '16', not: %d", n)
}
io.WriteString(w, "OK")
}
func (ts *TestServer) updateSessionVar(w http.ResponseWriter, r *http.Request) {
var err error
var sess session.Session
if sess, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
if err = sess.Set("Vek", 3); err != nil {
io.WriteString(w, err.Error())
}
io.WriteString(w, "OK")
}
func (ts *TestServer) validateUpdatedValue(w http.ResponseWriter, r *http.Request) {
var err error
var sess session.Session
if sess, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
fmt.Println(sess)
var n any
if n, err = sess.Get("Vek"); err != nil {
io.WriteString(w, err.Error())
}
if n.(int) != 3 {
io.WriteString(w, fmt.Sprintf("Value vek ater update must be: '3', not: '%d'", n.(int)))
}
io.WriteString(w, "OK")
}
func (ts *TestServer) deleteSessionValue(w http.ResponseWriter, r *http.Request) {
var err error
var sess session.Session
if sess, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
if sess.Delete("Vek"); err != nil {
io.WriteString(w, err.Error())
}
io.WriteString(w, "OK")
}
func (ts *TestServer) validateDeletedValue(w http.ResponseWriter, r *http.Request) {
var err error
var sess session.Session
if sess, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
var v any
if v, err = sess.Get("Vek"); err != nil {
io.WriteString(w, err.Error())
}
if v != nil {
io.WriteString(w, fmt.Sprintf("Session value 'Vek' must be nil after delete, not: %v", v))
}
io.WriteString(w, "OK")
}
func (ts *TestServer) sessionGC(w http.ResponseWriter, r *http.Request) {
var err error
if _, err = sm.SessionStart(w, r); err != nil {
io.WriteString(w, err.Error())
}
time.Sleep(200 * time.Millisecond)
io.WriteString(w, "OK")
}
type TestClient struct {
cl *http.Client
ckstore *cookiejar.Jar
}
func NewTestClient() (tc *TestClient, err error) {
var ckstore *cookiejar.Jar
if ckstore, err = cookiejar.New(nil); err != nil {
return nil, err
}
httpclient := &http.Client{Jar: ckstore}
tc = &TestClient{httpclient, ckstore}
return
}
// Request send http request into test server and return ResponseResult or error
func (tc *TestClient) Request(meth, uri string) (resb []byte, err error) {
var req *http.Request
if req, err = http.NewRequest(meth, uri, nil); err != nil {
return nil, fmt.Errorf("Create request to: %s ailed err: %v", uri, err)
}
var rs *http.Response
if rs, err = tc.cl.Do(req); err != nil {
return nil, fmt.Errorf("Request failed err: %v", err)
}
defer rs.Body.Close()
var body []byte
if body, err = io.ReadAll(rs.Body); err != nil {
return nil, err
}
return body, nil
}
func t1(t *testing.T) {
var res []byte
var err error
if res, err = tc.Request("GET", "http://localhost:8080/startsession/"); err != nil {
t.Logf("err: %v", err)
t.Error(err)
}
if string(res) != "OK" {
t.Errorf("Response from test request not OK, but: %s", res)
}
}
func t2(t *testing.T) {}
func t3(t *testing.T) {}
func t4(t *testing.T) {}
func t5(t *testing.T) {}
func t6(t *testing.T) {}
func t7(t *testing.T) {}
// iterate all session storage impls. and run t1 - t7
func TestRunAll(t *testing.T) {
ts := NewTestServer("localhost:8080")
ts.Serve()
for _, pn := range session.ProviderNames() {
var err error
tc, err = NewTestClient()
if sm, err = session.NewManager(pn, sid, lifet, true); err != nil {
t.Errorf("Session provider %s failed initialize err: %v", pn, err)
}
fns := []func(t *testing.T){t1, t2, t3, t4, t5, t6, t7}
for idx, fn := range fns {
t.Run(fmt.Sprintf("Test%d", idx+1), fn)
}
}
}