From 25f844d8418a93118d2a3ccd010ca0861f8b7024 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 2 Feb 2025 09:44:18 -0500 Subject: [PATCH] Server side sessions --- cmd/ratchetd/main.go | 14 +++++++++++++- go.mod | 1 + go.sum | 3 +++ internal/server/handlers.go | 31 +++++++++++++++++++++++-------- internal/server/routes.go | 10 ++++++---- internal/server/server.go | 7 +++++-- internal/server/templates.go | 6 ++++-- ui/html/base.go.tmpl | 3 +++ 8 files changed, 58 insertions(+), 17 deletions(-) diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index 9ba5e52..f170a06 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -6,10 +6,14 @@ import ( "log/slog" "net/http" "os" + "time" rdb "git.runcible.io/learning/ratchet/internal/database" "git.runcible.io/learning/ratchet/internal/logging" "git.runcible.io/learning/ratchet/internal/server" + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" + _ "github.com/mattn/go-sqlite3" // "git.runcible.io/learning/ratchet" // ratchethttp "git.runcible.io/learning/ratchet/internal" ) @@ -43,11 +47,19 @@ func main() { defer db.Close() tc, err := server.InitTemplateCache() + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + // SessionManager + sm := scs.New() + sm.Store = sqlite3store.New(db) + sm.Lifetime = 12 * time.Hour // Propagate build information to root package to share globally // ratchet.Version = strings.TrimPrefix(version, "") // ratchet.Commit = commit - server := server.NewRatchetServer(logger, tc, db) + server := server.NewRatchetServer(logger, tc, db, sm) // START SERVING REQUESTS slog.Debug("Herp dirp!") diff --git a/go.mod b/go.mod index db9f173..b6067a5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.3 require github.com/mattn/go-sqlite3 v1.14.24 require ( + github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 // indirect github.com/alexedwards/scs/v2 v2.8.0 // indirect github.com/go-playground/form/v4 v4.2.1 // indirect ) diff --git a/go.sum b/go.sum index 52a0516..eb987a7 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ +github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0= +github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw= github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8b514e5..97c86dd 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -10,11 +10,12 @@ import ( "git.runcible.io/learning/ratchet/internal/model" "git.runcible.io/learning/ratchet/internal/validator" + "github.com/alexedwards/scs/v2" "github.com/go-playground/form/v4" ) // TODO function should accept and a pointer to an interface allowing for mocking in tests. -func handleHome(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { +func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Retrieve Snippets from DB @@ -30,7 +31,7 @@ func handleHome(logger *slog.Logger, tc *TemplateCache, snippetService *model.Sn // data := templateData{ // Snippets: snippets, // } - data := newTemplateData() + data := newTemplateData(r, sm) data.Snippets = snippets renderTemplate(w, r, tc, http.StatusOK, "home.go.tmpl", data) @@ -62,7 +63,7 @@ func handleHome(logger *slog.Logger, tc *TemplateCache, snippetService *model.Sn }) } -func handleSnippetView(logger *slog.Logger, tc *TemplateCache, snippetService *model.SnippetService) http.Handler { +func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) @@ -85,6 +86,15 @@ func handleSnippetView(logger *slog.Logger, tc *TemplateCache, snippetService *m return } + // Use the PopString() method to retrieve the value for the "flash" key. + // PopString() also deletes the key and value from the session data, so it + // acts like a one-time fetch. If there is no matching key in the session + // data this will return the empty string. + + // See also GetInt, GetBool, GetBytes, GetTime etc. + // NOW DONE IN TEMPLATE DATA FUNC + // flash := sm.PopString(r.Context(), "flash") + // files := []string{ // "./ui/html/base.go.tmpl", // "./ui/html/partials/nav.go.tmpl", @@ -117,15 +127,16 @@ func handleSnippetView(logger *slog.Logger, tc *TemplateCache, snippetService *m // data := templateData{ // Snippet: snippet, // } - data := newTemplateData() + data := newTemplateData(r, sm) data.Snippet = snippet + data.Flash = flash renderTemplate(w, r, tc, http.StatusOK, "view.go.tmpl", data) }) } -func handleSnippetCreateGet(tc *TemplateCache) http.Handler { +func handleSnippetCreateGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - data := newTemplateData() + data := newTemplateData(r, sm) // Initialize a new snippetCreateForm instance and pass it to the template. // Notice how this is also a great opportunity to set any default or // 'initial' values for the form --- here we set the initial value for the @@ -140,7 +151,7 @@ func handleSnippetCreateGet(tc *TemplateCache) http.Handler { // snippetCreate handles display of the form used to create snippets // // curl -iL -d "" http://localhost:5001/snippet/create -func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder *form.Decoder, snippetService *model.SnippetService) http.Handler { +func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder *form.Decoder, sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // example of a custom header. Must be done before calling WriteHeader @@ -242,7 +253,7 @@ func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "this field cannot be blank") if !form.Valid() { - data := newTemplateData() + data := newTemplateData(r, sm) data.Form = form renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data) return @@ -264,6 +275,10 @@ func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder } logger.Info(fmt.Sprintf("Inserted record. id: %d", id)) + // Use the Put() method to add a string value ("Snippet successfully + // created!") and the corresponding key ("flash") to the session data. + sm.Put(r.Context(), "flash", "Snippet successfully created!") + http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther) }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 8d6dcda..b006bb5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,6 +6,7 @@ import ( "net/http" "git.runcible.io/learning/ratchet/internal/model" + "github.com/alexedwards/scs/v2" "github.com/go-playground/form/v4" ) @@ -14,16 +15,17 @@ func addRoutes(mux *http.ServeMux, tc *TemplateCache, db *sql.DB, fd *form.Decoder, + sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { // /{$} is used to prevent subtree path patterns from acting like a wildcard // resulting in this route requiring an exact match on "/" only // You can only include one HTTP method in a route pattern if you choose // GET will match GET & HEAD http request methods - mux.Handle("GET /{$}", handleHome(logger, tc, snippetService)) - mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, tc, snippetService)) - mux.Handle("GET /snippet/create", handleSnippetCreateGet(tc)) - mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, tc, fd, snippetService)) + mux.Handle("GET /{$}", sm.LoadAndSave(handleHome(logger, tc, sm, snippetService))) // might be time to swith to github.com/justinas/alice dynamic chain + mux.Handle("GET /snippet/view/{id}", sm.LoadAndSave(handleSnippetView(logger, tc, sm, snippetService))) + mux.Handle("GET /snippet/create", sm.LoadAndSave(handleSnippetCreateGet(tc, sm))) + mux.Handle("POST /snippet/create", sm.LoadAndSave(handleSnippetCreatePost(logger, tc, fd, sm, snippetService))) // mux.Handle("/something", handleSomething(logger, config)) // mux.Handle("/healthz", handleHealthzPlease(logger)) // mux.Handle("/", http.NotFoundHandler()) diff --git a/internal/server/server.go b/internal/server/server.go index a520dde..749d861 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,7 @@ import ( "net/http" "git.runcible.io/learning/ratchet/internal/model" + "github.com/alexedwards/scs/v2" "github.com/go-playground/form/v4" ) @@ -18,14 +19,16 @@ type RatchetServer struct { snippetService *model.SnippetService UserService model.UserService formDecoder *form.Decoder + sessionManager *scs.SessionManager } -func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *RatchetServer { +func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB, sm *scs.SessionManager) *RatchetServer { rs := new(RatchetServer) rs.logger = logger rs.snippetService = &model.SnippetService{DB: db} rs.formDecoder = form.NewDecoder() rs.templateCache = tc + rs.sessionManager = sm // TODO implement middleware that disables directory listings fileServer := http.FileServer(http.Dir("./ui/static/")) router := http.NewServeMux() @@ -35,7 +38,7 @@ func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *Ratch // Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver. // SEE we can really clean things up by moving this into routes.go and handlers.go - wrappedMux := addRoutes(router, rs.logger, rs.templateCache, db, rs.formDecoder, rs.snippetService) + wrappedMux := addRoutes(router, rs.logger, rs.templateCache, db, rs.formDecoder, sm, rs.snippetService) rs.Handler = CommonHeaderMiddleware(wrappedMux) rs.Handler = RequestLoggingMiddleware(rs.Handler, logger) rs.Handler = RecoveryMiddleware(rs.Handler) diff --git a/internal/server/templates.go b/internal/server/templates.go index e3c41fc..8b3928a 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -10,6 +10,7 @@ import ( "time" "git.runcible.io/learning/ratchet/internal/model" + "github.com/alexedwards/scs/v2" ) // Define a templateData type to act as the holding structure for @@ -21,11 +22,12 @@ type templateData struct { Snippet model.Snippet Snippets []model.Snippet Form any + Flash string } // newTemplateData is useful to inject default values. Example CSRF tokens for forms. -func newTemplateData() templateData { - return templateData{CurrentYear: time.Now().Year()} +func newTemplateData(r *http.Request, sm *scs.SessionManager) templateData { + return templateData{CurrentYear: time.Now().Year(), Flash: sm.PopString(r.Context(), "flash")} } // TEMPLATE FUNCTIONS diff --git a/ui/html/base.go.tmpl b/ui/html/base.go.tmpl index d9f03b1..2a6a93d 100644 --- a/ui/html/base.go.tmpl +++ b/ui/html/base.go.tmpl @@ -15,6 +15,9 @@ {{template "nav" .}}
+ {{with .Flash}} +
{{.}}
+ {{end}} {{template "main" .}}