You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

453 lines
17 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// test
package server
import (
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"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, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// Retrieve Snippets from DB
snippets, err := snippetService.Lastest()
if err != err {
serverError(w, r, err)
return
}
logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets)))
// Old way. We want default data so
// data := templateData{
// Snippets: snippets,
// }
data := newTemplateData(r, sm)
data.Snippets = snippets
renderTemplate(w, r, tc, http.StatusOK, "home.go.tmpl", data)
// // Initialize a slice containing the paths to the two files. It's important
// // to note that the file containing our base template must be the *first*
// // file in the slice.
// files := []string{
// "./ui/html/base.go.tmpl",
// "./ui/html/partials/nav.go.tmpl",
// "./ui/html/pages/home.go.tmpl",
// }
// read template file into template set.
// ts, err := template.ParseFiles(files...)
// ts, err := parseTemplateFiles(files...)
// if err != nil {
// serverError(w, r, err)
// return
// }
// // Write template content to response body
// err = ts.ExecuteTemplate(w, "base", data)
// if err != nil {
// // This is the older more verbose way of doing what RatchetServer.serverError does
// // rs.logger.Error(err.Error())
// // http.Error(w, "Internal Server Error", http.StatusInternalServerError)
// serverError(w, r, err)
// }
})
}
func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
clientError(w, http.StatusNotFound)
return
}
// Set a new cache-control header. If an existing "Cache-Control" header exists
// it will be overwritten.
// w.Header().Set("Cache-Control", "public, max-age=31536000")
snippet, err := snippetService.Get(id)
if err != nil {
logger.Debug(fmt.Sprintf("Failed to retrieve an active record with id: %d", id))
if errors.Is(err, model.ErrNoRecord) {
clientError(w, http.StatusNotFound)
} else {
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.
// 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",
// "./ui/html/pages/view.go.tmpl",
// }
// //ts, err := template.ParseFiles(files...)
// ts, err := parseTemplateFiles(files...)
// if err != nil {
// serverError(w, r, err)
// return
// }
// data := templateData{
// Snippet: snippet,
// }
// logger.Debug(fmt.Sprintf("created template: %s", ts.Name()))
// Any data that you pass as the final parameter to ts.ExecuteTemplate()
// is represented within your HTML templates by the . character (referred to as dot).
// In this specific case, the underlying type of dot will be a models.Snippet struct.
// When the underlying type of dot is a struct, you can render (or yield) the value
// of any exported field in your templates by postfixing dot with the field name
// field, we could yield the snippet title by writing {{.Title}} in our templates.
// err = ts.ExecuteTemplate(w, "base", data)
// if err != nil {
// serverError(w, r, err)
// }
// data := templateData{
// Snippet: snippet,
// }
data := newTemplateData(r, sm)
data.Snippet = snippet
// data.Flash = flash
renderTemplate(w, r, tc, http.StatusOK, "view.go.tmpl", data)
})
}
func handleSnippetCreateGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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
// snippet expiry to 365 days.
data.Form = snippetCreateForm{
Expires: 365,
}
renderTemplate(w, r, tc, http.StatusOK, "create.go.tmpl", data)
})
}
// 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, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// example of a custom header. Must be done before calling WriteHeader
// or they will fail to take effect.
w.Header().Add("Server", "Dirp")
// Limit the request body size to 4096 bytes
// by default it's 10mb unless enctype="multipart/form-data in which case
// thier is no limit. You would do this however to limit the number of bytes
// that r.ParseForm will process. In this case it is only the first 4096 bytes
// reading beyond that will cause the MaxBytesReader to throw and error surfaced
// in your call to r.ParseForm()
// Also if the limit is reached the reader flags the server which will terminate
// the under lying TCP connection.
r.Body = http.MaxBytesReader(w, r.Body, 4096)
// ParseForm is idempotent. Can be called many times.
// First we call r.ParseForm() which adds any data in POST request bodies
// to the r.PostForm map. This also works in the same way for PUT and PATCH
// requests. If there are any errors, we use our app.ClientError() helper to
// send a 400 Bad Request response to the user.
// If you have a form that submits data using the HTTP method GET, rather than POST,
// the form data will be included as URL query string parameters /foo/bar?title=value&content=value.
// can be retrieved using r.URL.Query().Get() which will return the value or an empty string.
// r.Form could be used, but it is clearer and more explicit to stick with r.URL.Query and r.PostForm
// to access submitted data.
err := r.ParseForm()
if err != nil {
clientError(w, http.StatusBadRequest)
return
}
// initializes the struct with zero values. So &form is non-nil
var form snippetCreateForm
// AUTOMATIC FORM PROCESSING
// When we call app.formDecoder.Decode() it requires a non-nil pointer as the target
// decode destination. If we try to pass in something that isnt a non-nil pointer, then
// Decode() will return a form.InvalidDecoderError error. That error should be handled
// differently form a 400 error since it's a server side problem (ie 5xx error)
err = decodePostForm(r, formDecoder, &form)
if err != nil {
clientError(w, http.StatusBadRequest)
return
}
// OLD WAY
// The r.PostForm.Get() method always returns the form data as a *string*.
// However, we're expecting our expires value to be a number, and want to
// represent it in our Go code as an integer. So we need to manually convert
// the form data to an integer using strconv.Atoi(), and we send a 400 Bad
// Request response if the conversion fails.
// expires, err := strconv.Atoi(r.PostForm.Get("expires"))
// if err != nil {
// clientError(w, http.StatusBadRequest)
// return
// }
// Create an instance of the snippetCreateForm struct containing the values
// from the form and an empty map for any validation errors.
// form := snippetCreateForm{
// Title: r.PostForm.Get("title"),
// Content: r.PostForm.Get("content"),
// Expires: expires,
// }
// VALIDATION
// THE OLD WAY
// if strings.TrimSpace(form.Title) == "" {
// form.FieldErrors["title"] = "field cannot be blank"
// // we want to count the number of unicode code points not bytes in the string
// } else if utf8.RuneCountInString(form.Title) > 100 {
// form.FieldErrors["title"] = "This field cannot exceed 100 characters"
// }
// if strings.TrimSpace(form.Content) == "" {
// form.FieldErrors["content"] = "field cannot be blank"
// }
// if expires != 1 && expires != 7 && expires != 365 {
// form.FieldErrors["expires"] = "This field must equal 1,7, or 365"
// }
// If there are any validation errors, then re-display the create.tmpl template,
// passing in the snippetCreateForm instance as dynamic data in the Form
// field. Note that we use the HTTP status code 422 Unprocessable Entity
// when sending the response to indicate that there was a validation error.
// if len(form.FieldErrors) > 0 {
// data := newTemplateData()
// data.Form = form
// renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data)
// return
// }
// New VALIDATION
form.CheckField(validator.NotBlank(form.Title), "title", "this field cannot be blank")
form.CheckField(validator.MaxChars(form.Title, 100), "title", "this field cannot exceed 100 characters")
form.CheckField(validator.NotBlank(form.Content), "content", "this field cannot be blank")
form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "this field cannot be blank")
if !form.Valid() {
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "create.go.tmpl", data)
return
}
// NOTE
//
// In the case of a form field with multiple values youll need to work with the
// r.PostForm map directly. The underlying type of the r.PostForm map is url.Values,
// which in turn has the underlying type map[string][]string. So, for fields with
// multiple values you can loop over the underlying map to access them like so:
// for i, item := range r.PostForm["items"] {
// fmt.Fprintf(w, "%d: Item %s\n", i, item)
// }
id, err := snippetService.Insert(form.Title, form.Content, form.Expires)
if err != nil {
serverError(w, r, err)
}
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)
})
}
func handleUserSignupGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := newTemplateData(r, sm)
data.Form = userSignupForm{}
renderTemplate(w, r, tc, http.StatusOK, "signup.go.tmpl", data)
})
}
func handleUserSignupPost(logger *slog.Logger, tc *TemplateCache, fd *form.Decoder, sm *scs.SessionManager, userService model.UserServiceInterface) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check that the provided name, email address and password are not blank.
// Sanity check the format of the email address.
// Ensure that the password is at least 8 characters long.
// Make sure that the email address isnt already in use.
err := r.ParseForm()
if err != nil {
logger.Error("Failed to parse signup form")
clientError(w, http.StatusBadRequest)
return
}
form := userSignupForm{}
err = decodePostForm(r, fd, &form)
if err != nil {
logger.Error("Failed to decode signup form")
clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Name), "name", "this field cannot be blank")
form.CheckField(validator.NotBlank(form.Email), "email", "this field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "this field must be a valid email address")
form.CheckField(validator.NotBlank(form.Password), "password", "this field cannot be blank")
form.CheckField(validator.MinChars(form.Password, 8), "password", "this field must be at least 8 characters long")
// Todo Email allready in use validation
if !form.Valid() {
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "signup.go.tmpl", data)
}
_, err = userService.Insert(form.Name, form.Email, form.Password)
if err != nil {
if errors.Is(err, model.ErrDuplicateEmail) {
form.AddFieldError("email", "Email is already in use")
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "signup.go.tmpl", data)
} else {
serverError(w, r, err)
}
return
}
sm.Put(r.Context(), "flash", "Your signup was successful. Please log in.")
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
})
}
func handleUserLoginGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := newTemplateData(r, sm)
data.Form = userLoginForm{}
renderTemplate(w, r, tc, http.StatusOK, "login.go.tmpl", data)
})
}
func handleUserLoginPost(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, fd *form.Decoder, userService model.UserServiceInterface) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// parse form
err := r.ParseForm()
if err != nil {
logger.Error("Failed to parse login form")
clientError(w, http.StatusBadRequest)
return
}
form := userLoginForm{}
err = decodePostForm(r, fd, &form)
if err != nil {
logger.Error("Failed to decode login form")
clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
if !form.Valid() {
logger.Info("An invalid form was submitted")
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "login.go.tmpl", data)
return
}
id, err := userService.Authenticate(form.Email, form.Password)
if err != nil {
if errors.Is(err, model.ErrInvalidCredentials) {
logAuthFailure(logger, r, form.Email)
form.AddNonFieldError("Email or password is incorrect")
data := newTemplateData(r, sm)
data.Form = form
renderTemplate(w, r, tc, http.StatusUnprocessableEntity, "login.go.tmpl", data)
} else {
serverError(w, r, err)
}
return
}
// Use the RenewToken() method on the current session to change the session ID.
// It's good practice to generate a new session ID when the authentication state
// or privilege levels change for the user (e.g. login and logout operations)
// This changes the ID of the current users session but retain any data
// associated with the session
// see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md#renew-the-session-id-after-any-privilege-level-change
err = sm.RenewToken(r.Context())
if err != nil {
serverError(w, r, err)
return
}
// Add the ID of the current user to the session, so that they are now "logged in"
sm.Put(r.Context(), "authenticatedUserID", id)
logAuthSuccess(logger, r, form.Email, id)
http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
})
}
func handleUserLogoutPost(logger *slog.Logger, sm *scs.SessionManager) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use RenewToken on the current session to change the session ID
err := sm.RenewToken(r.Context())
if err != nil {
serverError(w, r, err)
}
userId := sm.GetString(r.Context(), "authenticatedUserID")
if userId == "" {
logger.Info("No athenticated user in session")
} else {
logger.Info(fmt.Sprintf("Logging out user: %s", userId))
}
// Remove the authenticatedUserID from the session data
sm.Remove(r.Context(), "authenticatedUserID")
// Add a flash message
sm.Put(r.Context(), "flash", "You've been logged out successfully!")
http.Redirect(w, r, "/", http.StatusSeeOther)
})
}
func PingHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
}