8. HTTP con estados

Una buena mejora para la experiencia de usuario podira ser mostrar una notificacion de que el snippet fue agregado correctamente.

Una notificacion como esta solo se le mostrara al usuario una vez, inmeditamente depues de haber sido crado en el snippet y ningun otro usuario deberia poder ver ese mensaje, normalmente a esta funcionalidad se las llama flash message o toast.

Para hacer este trabajo, necesitamos empezar por compartir los datos (o el estado) entre la peticion HTTP del mismo usuario que la envia. La manera mas comun es implementando una session para el usuario.

En este post veremos:

  • Que session managers tenemos disponibles para implementar sesiones en Go.
  • Como usar sesiones de manera segura entre solicitudes de un determinado usuario.
  • Como podemos personalizar el comportamiento de la sesion (incuyendo timeouts y configuracionse de cookie) basandonos en lo que la aplicacion necesita.

8.1 Eligiendo un manejador de sesion.

Hay varias consideraciones de seguridad cuando trabajamos con sesiones, y la implementacion adecuada no es algo trivial. Al menos que realmente necesites crear tu propia implementacion, es buena idea uasr una existente y bien testeada libreria de terceros.

Yo recomiendo o usar gorilla/sessions o alexedwards/scs dependiendo de lo que tu aplicacion necesite.

  • gorilla/sessions Es el mas establecido y bien conocido manejador de sesiones para GO tiene un simple y facil de usar API que nos permite guardar los datos de sesion del lado del cliente (en cookies firmadas y encriptadas) o server-side en una base de datos como MySQL , PostgreSQL o Redis.

Sin embargo no provee un mecanismo para renovar session IDs lo cual es necesario para reducir el riesgo asociado con session fixation attacks si estas usando alguno de los almacenamientos del server-side.

  • alexedwards/scs nos permite almacenar la sesion solo del lado del servidor. Soporta carga automatica y guardado de datos de sesion via middleware, con una buena interfaz para tipado seguro al manejar los datos y nos permite la renovacion de session IDs. al igual que gorilla/sessions soporta una variedad de bases de datos como MySQL , PostgreSQL o Redis.

En resumen si queremos almacenar los datos de sesion del lado del cliente entonces gorilla/sessions es una buena eleccion , pero de lo contrario alexdwards/scs en general es mejor opcion ya que cuenta con renovacion de sessions IDs.

En este proyecto usaremos alexdwards/scs para guardar los datos de sesion del lado del servidor con MySQL.

Comencemos con la instalacion.

$ go get github.com/alexedwards/scs/v2@v2
go: downloading github.com/alexedwards/scs/v2 v2.5.1
go get: added github.com/alexedwards/scs/v2 v2.5.1
$ go get github.com/alexedwards/scs/mysqlstore@latest
go: downloading github.com/alexedwards/scs/mysqlstore v0.0.0-20230902070821-95fa2ac9d520
go get: added github.com/alexedwards/scs/mysqlstore v0.0.0-20230902070821-95fa2ac9d520

8.2 Configurando el manejador de sesion.

En esta seccion vamos a ir a traves de la configuracion basica y usaremos alexdwards/scs , si vas a usarlo para produccion de una app, recomiendo leer la documentacion y la referencia a la API para familiarizarte con todas las carecteristicas.

Lo primero que necesitamos hacer es crear una tabla sessions en nuestra base de datos para mantener los datos de sesion de nuestros usuarios. Empecemos conectandonos a MySQL por la terminal como usuario root y ejecutemos la siguient declaracion para crear la tabla sessions.

USE snippetbox;

CREATE TABLE sessions (
   token CHAR(43) PRIMARY KEY,
   data BLOB NOT NULL,
   expiry TIMESTAMP(6) NOT NULL
);

CREATE INDEX sessions_expiry_idx ON sessions (expiry);

En esta tabla:

  • El campo token contendra un unico y aleatorio identificador para cada sesion.
  • el campo data contendra los datos de la sesion actual que queremos compartir entre las peticiones HTTP. Este es almacenado en binary data de tipo blob(binary large object).
  • el campo expiry contendra el tiempo de expiracion de la sesion, el paquete scs automaticamente eliminara las sesiones expiradas de la tabla sessions asi que no crecera demaciado.

Lo siguiente que tenemos que hacer es establecer el manejador de sesiones en nuestro main.go hacer que este disponible en nuestros handlers a traves de la estructura application. El manejador de sasion mantendra la configuracion para nuestras sesioones y proveera algunos middlweares y helpers para manejar la carga y el guardado de los datos de sesion.

Vamos a main.go y hagamos las modificaciones.

 package main

 import (
	"database/sql"
	"flag"
	"html/template"
	"log"
	"net/http"
	"os"
	"time"

	"log/slog"

+	"github.com/alexedwards/scs/mysqlstore"
+	"github.com/alexedwards/scs/v2"
	"github.com/go-playground/form/v4"
	_ "github.com/go-sql-driver/mysql"
	"github.com/nahueldev23/snippetbox/internal/models"
 )

 type application struct {
	logger         *slog.Logger
	errorLog       *log.Logger
	infoLog        *log.Logger
	snippets       *models.SnippetModel
	templateCache  map[string]*template.Template
	formDecoder    *form.Decoder
+	sessionManager *scs.SessionManager
 }

 func main() {
 	addr := flag.String("addr", ":4000", "HTTP network address")
   
+  //pone tu password
 	dsn := flag.String("dsn", "web:xxxx/snippetbox?parseTime=true", "HTTP network address")
 
 	flag.Parse()
 
 	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
 
 	infoLog := log.New(os.Stdout, "INFO \t", log.Ldate|log.Ltime)
 	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
 
 	db, err := openDB(*dsn)
 	if err != nil {
 		errorLog.Fatal(err)
 	}
 
 	defer db.Close()
 	templateCache, err := newTemplateCache()
 	if err != nil {
 		errorLog.Fatal(err)
 	}
 
 	// Initialize a decoder instance...
 	formDecoder := form.NewDecoder()
 
+	// Use the scs.New() function to initialize a new session manager. Then we
+	// configure it to use our MySQL database as the session store, and set a
+	// lifetime of 12 hours (so that sessions automatically expire 12 hours
+	// after first being created).
+	sessionManager := scs.New()
+	sessionManager.Store = mysqlstore.New(db)
+	sessionManager.Lifetime = 12 * time.Hour
 
 	app := &application{
 		logger:         logger,
 		errorLog:       errorLog,
 		infoLog:        infoLog,
 		snippets:       &models.SnippetModel{DB: db},
 		templateCache:  templateCache,
 		formDecoder:    formDecoder,
+		sessionManager: sessionManager,
 	}
 
 	srv := &http.Server{
 		Addr:     *addr,
 		ErrorLog: errorLog,
 		Handler:  app.routes(),
 	}
 
 	logger.Info("Starting server", "addr", *addr)
 
 	err = srv.ListenAndServe()
 	logger.Error(err.Error())
 	os.Exit(1)
 }
 
 func openDB(dsn string) (*sql.DB, error) {
 	db, err := sql.Open("mysql", dsn)
 	if err != nil {
 		return nil, err
 	}
 	if err = db.Ping(); err != nil {
 		return nil, err
 	}
 	return db, nil
 }
 

La funcion retorna un puntero a la estructura SessionManager el cual contiene la configuracion para nuestras sesiones, en el codigo anterior seteamos Store y Lifetime de esta estructura , pero hay otros campos que podes y deberias configurar dependiendo de las necesitades de tu app.

Para que nuetras sesiones funcionen, tambien tenemos que envolver nuestras rutas con el middleware que nos provee el metodo SessionManager.LoadAndSave(), este middleare automaticamente cargara y guardara los datos de sesion de cada peticion y respuesta HTTP.

Es importante notar que no necesitamos este middleware en toda nuestra app. Especialmente no necesitamos tenerla en /static/*filepath , porque todas ellas sirven archivos estaticos y no es necseario tener ningun comportamiento de estado.

Por eso no tiene sentido agregar el middleware de sesion a nuestras cadenas de middleware standar.

En su lugar vamos a crear una nueva cadena dynamic que contenga el middleware apropiado para nuestras rutas dinamicas.

Vamos a routes.go

  package main
  
  import (
  	"net/http"
  
  	"github.com/julienschmidt/httprouter"
  	"github.com/justinas/alice" // New import
  )
  
  func (app *application) routes() http.Handler {
  
  	router := httprouter.New()
  
  	router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		app.notFound(w)
  	})
  
  	fileServer := http.FileServer(http.Dir("./ui/static/"))
  	router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
  
+  	// Create a new middleware chain containing the middleware specific to our
+  	// dynamic application routes. For now, this chain will only contain the
+  	// LoadAndSave session middleware but we'll add more to it later.
+  	dynamic := alice.New(app.sessionManager.LoadAndSave)
  
+  	// Update these routes to use the new dynamic middleware chain followed by
+  	// the appropriate handler function. Note that because the alice ThenFunc()
+  	// method returns a http.Handler (rather than a http.HandlerFunc) we also
+  	// need to switch to registering the route using the router.Handler() method.
+  	router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
+  	router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
+  	router.Handler(http.MethodGet, "/snippet/create", dynamic.ThenFunc(app.snippetCreate))
+  	router.Handler(http.MethodPost, "/snippet/create", dynamic.ThenFunc(app.snippetCreatePost))

-   router.HandlerFunc(http.MethodGet, "/", app.home)
-  	router.HandlerFunc(http.MethodGet, "/snippet/view/:id", app.snippetView)
-  	router.HandlerFunc(http.MethodGet, "/snippet/create", app.snippetCreate)
-  	router.HandlerFunc(http.MethodPost, "/snippet/create", app.snippetCreatePost)
  	// Create the middleware chain as normal.
  	standard := alice.New(app.recoverPanic, app.logRequest, sercureHeaders)

  	// Wrap the router with the middleware and return it as normal.
  	return standard.Then(router)
}

Si reniciamos el servidor deberia seguir funcionando todo bien.

8.2 Informacion adicional

8.2 Sin el paquete alice

Si no estas usando el paquete justina/alice para que te ayude con las cadenas de middleware entonces tenes que usar el adaptador http.HandlerFunc() para convertir las funciones handlers como app.home en un http.Handler y luego envolverlos con el middleware de sesion, asi:

router := httprouter.New()
router.Handler(http.MethodGet, "/", app.sessionManager.LoadAndSave(http.HandlerFunc(app.home)))
router.Handler(http.MethodGet, "/snippet/view/:id", app.sessionManager.LoadAndSave(http.HandlerFunc(app.snippetView)))
// ... etc

8.3 Trabajando con los datos de sesion.

En esta seccion vamos a poner a funcionar la sesion para que persista el mensaje flash entre las peticiones HTTP.

Vamos a cmd/web/handlers.go y actualicemos el metodo snippetCreatePost para que el mensaje sea agreado a la sesion del usuario si es que el snippet fue creado con exito:

  func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
  	var form snippetCreateForm
  
  	err := app.decodePostForm(r, &form)
  	if err != nil {
  		app.clientError(w, http.StatusBadRequest)
  		return
  	}
  
  	form.CheckField(validator.NotBlank(form.Title), "title", "This field cannot be blank")
  	form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long")
  	form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
  	form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7 or 365")
  
  	if !form.Valid() {
  		data := app.newTemplateData(r)
  		data.Form = form
  		app.render(w, r, http.StatusUnprocessableEntity, "create.html", data)
  		return
  	}
  	id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
  	if err != nil {
  		app.serverError(w, r, err)
  		return
  	}
  
+	 // Use the Put() method to add a string value ("Snippet successfully
+	 // created!") and the corresponding key ("flash") to the session data.
+	 app.sessionManager.Put(r.Context(), "flash", "Snippet successfully created!")
  
  	// Update the redirect path to use the new clean URL format.
  	http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
  }

Hay un par de cosas que explicar en esto:

  • El primer parametro que nosotros pasamos a app.sessionManager.Put() es el current request context. Hablaremos sobre que es el request context y como usarlo en post siguientes, pero por ahora simplemente podemos pensarlo como informacion que el session manager alamacena temporalmente mientras que nuestro handler atiende la peticion.
  • El segundo parametro (en nuestro caso el string “flash”) es la key del mensaje especifico que estamos agregando a los datos de sesion. Luego recibiremos el mensaje de la sesion usando esa misma key.
  • Si no hay una sesion para el usuario actual o si la sesion expiro,entonces una nueva sesion vacia se creara automaticamente por el middleware de sesion.

Lo siguiente que queremos es que nuestro handler snippetView reciba el mensaje flash ( si es que existe en la sesion del usuario actual) y pasrlo al template HTML para mostrarlo.

Como nosotros queremos mostrar el mensaje flash solo una vez, lo que vamos a hacer es recibirlo y luego removerlo de los datos de sesion. Podemos hacer ambas operaciones usando el metodo PopString()

Vamos a cmd/web/handlers.go

  func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
  
  	params := httprouter.ParamsFromContext(r.Context())
  
  	id, err := strconv.Atoi(params.ByName("id"))
  	if err != nil || id < 1 {
  		app.notFound(w)
  		return
  	}
  	snippet, err := app.snippets.Get(id)
  	if err != nil {
  		if errors.Is(err, models.ErrNoRecord) {
  			app.notFound(w)
  		} else {
  			app.serverError(w, r, err)
  		}
  		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.
+  	flash := app.sessionManager.PopString(r.Context(), "flash")
  
  	data := app.newTemplateData(r)
  	data.Snippet = snippet
  
+  	// Pass the flash message to the template.
+  	data.Flash = flash
  
  	app.render(w, r, http.StatusOK, "view.html", data)
}

Si queres recuperar el valor de la sesion y dejarlo ahi podemos usar en su lugar el metodo GetString(). El paquete scs tambien provee metodos para recibir otros tipos de datos comunes , como GetInt() , GetBool() , GetBytes() y GetTime() .

Antes de reinicar el servidor vamos a agreagar el campo Flash en la estructura tamplateDate , vamos a cmd/web/templates.go.

package main

import (
	"html/template"
	"path/filepath"
	"time"

	"github.com/nahueldev23/snippetbox/internal/models"
)

type templateData struct {
	CurrentYear int
	Snippet     models.Snippet
	Snippets    []models.Snippet
	Form        any
	Flash       string
}
...

Ahora podemos actualizar nuestro base.html para mostrar el mensaje flash, si es que existe.

{{define "base"}}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{{template "title" .}} - Snippetbox</title>
    <!-- Link to the CSS stylesheet and favicon -->
    <link rel="stylesheet" href="/static/css/main.css" />
    <link
      rel="shortcut icon"
      href="/static/img/favicon.ico"
      type="image/x-icon"
    />
    <!-- Also link to some fonts hosted by Google -->
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css?		family=Ubuntu+Mono:400,700"
    />
  </head>

  <body>
    <header>
      <h1><a href="/">Snippetbox</a></h1>
    </header>
    {{template "nav" .}}
+    <!-- Display the flash message if one exists -->
+    {{with .Flash}}
+    <div class="flash">{{.}}</div>
+    {{end}}
    <main>{{template "main" .}}</main>
    <footer>
      Powered by <a href="https://golang.org/">Go</a> in {{.CurrentYear}}
    </footer>
    <!-- And include the JavaScript file -->
    <script src="/static/js/main.js" type="text/javascript"></script>
  </body>
</html>
{{end}}

Guardemos todo y reinciemos el server , agrega un nuevo snippet, despues de la redireccion deberas ver el mensaje! si refrescas la pagina luego de haber visto el mensaje, ya no estara mas

8.3 Mostrar automaticamente mensajes flash.

Una mejora que nos va hacer ahorrar mucho tiempo en lo siguentes post es hacer que se muestre el mensaje flash automaticamente en la siguente pagina que se vaya a renderizar.

Podemos hacer esto agregando cualquier mensaje flash a traves del helper newTemplateData() que hicimos en post anteriores. Vamos a cmd/web/helpers.go.

 package main

 func (app *application) newTemplateData(r *http.Request) *templateData {
 	return &templateData{
 		CurrentYear: time.Now().Year(),
+ 		// Add the flash message to the template data, if one exists.
+ 		Flash: app.sessionManager.PopString(r.Context(), "flash"),
 	}
 }

Hacer este cambio significa que ya no necesitamos revisar el mensaje flash dentro del handler snippetView y el codigo puede ser revertido.

  func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
  
  	params := httprouter.ParamsFromContext(r.Context())
  
  	id, err := strconv.Atoi(params.ByName("id"))
  	if err != nil || id < 1 {
  		app.notFound(w)
  		return
  	}
  	snippet, err := app.snippets.Get(id)
  	if err != nil {
  		if errors.Is(err, models.ErrNoRecord) {
  			app.notFound(w)
  		} else {
  			app.serverError(w, r, err)
  		}
  		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.
-  	flash := app.sessionManager.PopString(r.Context(), "flash")
  
  	data := app.newTemplateData(r)
  	data.Snippet = snippet
  
  	// Pass the flash message to the template.
  	data.Flash = flash
  
  	app.render(w, r, http.StatusOK, "view.html", data)
  }

Reinciemos el servidor y comprobemos que todo sigue funcionando.

8.3 Informacion adicional

8.3 Detras de escenas del manejador de sesion

Si abrimos nuestro navegador y vamos a la seccion de cookies veremos que tenemos un cookie llamada session con un valor algo parecido a esto : dqraRtIq31Xb_ee5d0Ij6in3aaT6ymq6EEDgHyx7wQs , la tuya sera diferente.

Esa es la cookie de sesion y sera enviada a la aplicacion en cada peticion que tu navegador haga.

La cooki de sesion tiene el token de sesion o session token (tambien conocida como session ID) Es importante decir que ese token de sesion es oslo un string random, en si mismo no lleva ni transmite ningun tipo de informacion ( como el mensaje flash que configuramos antes).

Lo siguiente , si vamos a nuestra terminal e ingresamos a nuestra DB podremos ver el token, la data y el campo expires algo asi:

mysql> SELECT * FROM sessions WHERE token = 'y9y1-mXyQUoAM6V5s9lXNjbZ_vXSGkO7jy-KL-di7A4';
+---------------------------------------------+--------------------------------------------------------------------------------------------------
| token
| data
+---------------------------------------------+--------------------------------------------------------------------------------------------------
| y9y1-mXyQUoAM6V5s9lXNjbZ_vXSGkO7jy-KL-di7A4 | 0x26FF81030102FF820001020108446561646C696E6501FF8400010656616C75657301FF8600000010FF8305010104546
+---------------------------------------------+--------------------------------------------------------------------------------------------------
1 row in set (0.00 sec)

Esto deberia retornar un solo registro, el valor data actualmente contiene los datos de sesion del usuario en formato BLOB (binary large object).

Cada vez que hagamos un cambio en los datos de sesion , esta data se actualizara.

Lo que sucede en nuestra app es que el middleware LoadAndSave() revisa que cada peticion tenga una cookie de sesion, si la cookie esta presente ,lee el token y recibe los datos de sesion correspondientes de la db ( siempre y cuando la sesion no hay expirado) y luego agrega los datos de sesion al request context para que puedas usarlo en los handlers.

Cada cambio que hagas de los datos de sesion en tus handlers seran actualizados en la request context y luego el middleware LoadAndSave actualizara la DB con los cambios antes de ser retornado.