From 5e3dfacc80cecff84d1398568465cb472b14bd0e Mon Sep 17 00:00:00 2001 From: "sashakoshka@tebibyte.media" Date: Fri, 13 Dec 2024 22:11:24 -0500 Subject: [PATCH] providers: Incomplete session support Interestingly, attempting to compile and run ./cmd/stepd crashes the Go compiler (Chimera, v1.23.3). Unknown line number, nil panic. --- providers/all.go | 2 + providers/session/session.go | 219 +++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 providers/session/session.go diff --git a/providers/all.go b/providers/all.go index 576d122..00c6fe2 100644 --- a/providers/all.go +++ b/providers/all.go @@ -9,6 +9,7 @@ import fpmime "git.tebibyte.media/sashakoshka/step/providers/mime" import fpsprig "git.tebibyte.media/sashakoshka/step/providers/sprig" import fpimport "git.tebibyte.media/sashakoshka/step/providers/import" import fpstrings "git.tebibyte.media/sashakoshka/step/providers/strings" +import fpsession "git.tebibyte.media/sashakoshka/step/providers/session" import fpvalidate "git.tebibyte.media/sashakoshka/step/providers/validate" import fpmarkdown "git.tebibyte.media/sashakoshka/step/providers/markdown" @@ -22,6 +23,7 @@ func All () []step.Provider { new(fpmime.Provider), new(fpsprig.Provider), new(fpimport.Provider), + new(fpsession.Provider), new(fpstrings.Provider), new(fpvalidate.Provider), fpmarkdown.Default(), diff --git a/providers/session/session.go b/providers/session/session.go new file mode 100644 index 0000000..edad790 --- /dev/null +++ b/providers/session/session.go @@ -0,0 +1,219 @@ +package os + +import "log" +import "time" +import "net/http" +import "html/template" +import "github.com/google/uuid" +import "git.tebibyte.media/sashakoshka/step" +import "git.tebibyte.media/sashakoshka/goutil/sync" +import "git.tebibyte.media/sashakoshka/goutil/container" +import shttp "git.tebibyte.media/sashakoshka/step/http" + +const sessionIDCookieName = "step-session-id" +const defaultLifetime = 48 * time.Hour + +var _ step.FuncProviderFor = new(Provider) +var _ step.Initializer = new(Provider) +var _ step.Configurable = new(Provider) +var _ step.Trimmer = new(Provider) + +// Provider provides session functions. +type Provider struct { + Lifetime time.Duration + sessions usync.RWLocker[sessionMap] +} + +// Package fulfills the step.Provider interface. +func (this *Provider) Package () string { + return "session" +} + +func (this *Provider) Init () error { + this.sessions = usync.NewRWLocker(make(sessionMap)) + return nil +} + +func (this *Provider) Trim () { + sessions, done := this.sessions.Borrow() + defer done() + stale := ucontainer.Set[uuid.UUID] { } + for id, session := range sessions { + if session.Expired() { + stale.Add(id) + } + } + for id := range stale { + delete(sessions, id) + } +} + +func (this *Provider) Configure (config step.Meta) error { + if lifetimeStr := config.Get("session.lifetime"); lifetimeStr != "" { + lifetime, err := time.ParseDuration(lifetimeStr) + if err != nil { return err } + this.Lifetime = lifetime + } + return nil +} + +// FuncMapFor fulfills the step.FuncProviderFor interface. +func (this *Provider) FuncMapFor (document *step.Document) template.FuncMap { + stat := &state { + document: document, + sessions: &this.sessions, + } + return template.FuncMap { + "sessionHTTP": stat.funcSessionHTTP, + "session": stat.funcSession, + } +} + +type state struct { + document *step.Document + sessions *usync.RWLocker[sessionMap] + lifetime time.Duration +} + +func (this *state) funcSessionHTTP ( + res shttp.WrappedResponseWriter, + req *http.Request, +) ( + *Session, + error, +) { + var id uuid.UUID + if cookie, err := req.Cookie(sessionIDCookieName); err == nil { + if parsed, err := uuid.Parse(cookie.Value); err == nil { + id = parsed + } + } + var expiration time.Time + if this.lifetime == 0 { + expiration = time.Now().Add(defaultLifetime) + } else { + expiration = time.Now().Add(this.lifetime) + } + var result *session + if session, ok := this.checkSession(id); ok { + if this.lifetime != 0 { + session.setExpiration(expiration) + } + result = session + } else { + session, err := this.startSession(expiration) + if err != nil { return nil, err } + result = session + } + cookie := &http.Cookie { + Name: sessionIDCookieName, + Value: result.ID().String(), + Expires: expiration, + } + http.SetCookie(shttp.UnderlyingResponseWriter(res), cookie) + return result, nil +} + +func (this *state) funcSession (id uuid.UUID) (*session, error) { + expires := time.Now().Add(this.lifetime) + if session, ok := this.checkSession(id); ok { + session.setExpiration(expires) + return session, nil + } + return this.startSession(expires) +} + +func (this *state) checkSession (id uuid.UUID) (*session, bool) { + sessions, done := this.sessions.RBorrow() + defer done() + session, ok := sessions[id] + log.Println(ok) + if !ok || session.Expired() { return nil, false } + return session, true +} + +func (this *state) startSession (expires time.Time) (*session, error) { + sessions, done := this.sessions.Borrow() + defer done() + id := uuid.New() + // air defense + if _, exists := sessions[id]; exists { return nil, step.ErrPigsFlying } + session := &Session { + parent: this, + id: id, + data: usync.NewRWLocker(make(map[string] any)), + expires: usync.NewRWLocker(expires), + } + sessions[id] = session + log.Println(id) + return session, nil +} + +type sessionMap = map[uuid.UUID] *Session + +type Session struct { + parent *state // immutable + id uuid.UUID // immutable + data usync.RWLocker[map[string] any] // mutable + expires usync.RWLocker[time.Time] // mutable +} + +func (this *Session) ID () uuid.UUID { + return this.id +} + +func (this *Session) Get (name string) any { + data, done := this.data.RBorrow() + defer done() + return data[name] +} + +func (this *Session) Set (name string, value any) string { + data, done := this.data.Borrow() + defer done() + data[name] = value + return "" +} + +func (this *Session) Del (name string) string { + data, done := this.data.Borrow() + defer done() + delete(data, name) + return "" +} + +func (this *Session) Clear () string { + data, done := this.data.Borrow() + defer done() + clear(data) + return "" +} + +func (this *Session) Close () (string, error) { + sessions, done := this.parent.sessions.Borrow() + defer done() + if _, ok := sessions[this.id]; ok { + return "", step.ErrDoubleClose + } + delete(sessions, this.id) + this.Clear() + return "", nil +} + +func (this *Session) Expiration () time.Time { + expiration, done := this.expires.RBorrow() + defer done() + return expiration // ok because its not a pointer +} + +func (this *Session) setExpiration (expires time.Time) string { + // not public because this only sets the expiration time of the session, + // and not the session cookie it represents because that would require + // breaking into the user's computer. + this.expires.Set(expires) + return "" +} + +func (this *Session) Expired () bool { + return this.Expiration().After(time.Now()) +}