diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 7fbf19e..bd94c1f 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -4,11 +4,14 @@ import ( "bytes" "errors" "fmt" + "io/fs" "net/http" "strconv" + "strings" "gitea.local.lab/Lbenedar/snippetbox/internal/models" "gitea.local.lab/Lbenedar/snippetbox/internal/validator" + "gitea.local.lab/Lbenedar/snippetbox/ui" "github.com/julienschmidt/httprouter" ) @@ -32,6 +35,20 @@ type userLoginForm struct { validator.Validator `form:"-"` } +type accountViewForm struct { + Name string `form:"name"` + Email string `form:"email"` + Joined string `form:"joined"` + validator.Validator `form:"-"` +} + +type passwordChangeForm struct { + CurrentPassword string `form:"curr_pass"` + NewPassword string `form:"new_pass"` + ConfirmPassword string `form:"conf_pass"` + validator.Validator `form:"-"` +} + func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) { ts, ok := app.templateCache[page] if !ok { @@ -210,7 +227,11 @@ func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) { } app.sessionManager.Put(r.Context(), "authenticatedUserID", id) - + redirectPath := app.sessionManager.PopString(r.Context(), "redirectParent") + if redirectPath != "" { + http.Redirect(w, r, redirectPath, http.StatusSeeOther) + return + } http.Redirect(w, r, "/snippet/create", http.StatusSeeOther) } @@ -241,3 +262,73 @@ func (app *application) userLogin(w http.ResponseWriter, r *http.Request) { func ping(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) } + +func (app *application) about(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + text, err := fs.ReadFile(ui.Files, "static/text/about.txt") + if err != nil { + app.serverError(w, err) + return + } + data.AboutText = string(text) + app.render(w, http.StatusOK, "about.tmpl", data) +} + +func (app *application) accountView(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + id := app.sessionManager.Get(r.Context(), "authenticatedUserID").(int) + + user, err := app.users.GetById(id) + if err != nil { + app.serverError(w, err) + return + } + data.Form = accountViewForm{Name: user.Name, Email: user.Email, Joined: humanDate(user.Created)} + app.render(w, http.StatusOK, "account.tmpl", data) +} + +func (app *application) accountChangePassword(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + data.Form = passwordChangeForm{} + app.render(w, http.StatusOK, "password.tmpl", data) +} + +func (app *application) accountChangePasswordPost(w http.ResponseWriter, r *http.Request) { + form := passwordChangeForm{} + err := app.decodePostForm(r, &form) + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + form.CheckField(validator.NotBlank(form.CurrentPassword), "curr_pass", "This field cannot be blank") + form.CheckField(validator.NotBlank(form.NewPassword), "new_pass", "This field cannot be blank") + form.CheckField(validator.MinChars(form.NewPassword, 8), "new_pass", "Minimum password length is 8 symbols") + form.CheckField(validator.NotBlank(form.ConfirmPassword), "conf_pass", "This field cannot be blank") + form.CheckField(strings.Compare(form.NewPassword, form.ConfirmPassword) == 0, "conf_pass", "Confirm password does not match") + + if !form.Valid() { + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data) + return + } + + id := app.sessionManager.Get(r.Context(), "authenticatedUserID").(int) + err = app.users.ChangePassword(id, form.CurrentPassword, form.NewPassword) + if err != nil { + if errors.Is(err, models.ErrInvalidCredentials) { + form.AddNonFieldError("Password is incorrect") + + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data) + } else { + app.serverError(w, err) + } + return + } + + app.sessionManager.Put(r.Context(), "flash", "Password successfully changed!") + http.Redirect(w, r, "/account/view", http.StatusSeeOther) +} diff --git a/cmd/web/handlers_test.go b/cmd/web/handlers_test.go index 42574e7..3fdc55d 100644 --- a/cmd/web/handlers_test.go +++ b/cmd/web/handlers_test.go @@ -192,3 +192,37 @@ func TestUserSignup(t *testing.T) { }) } } + +func TestSnippetCreate(t *testing.T) { + app := newTestApplication(t) + ts := newTestServer(t, app.routes()) + defer ts.Close() + + const ( + validPassword = "validPa$$word" + validEmail = "bob@example.com" + ) + + t.Run("Unauthenticated", func(t *testing.T) { + code, head, _ := ts.get(t, "/snippet/create") + assert.Equal(t, code, http.StatusSeeOther) + assert.Equal(t, head.Get("Location"), "/user/login") + }) + + t.Run("Authenticated", func(t *testing.T) { + _, _, body := ts.get(t, "/user/login") + validCsrfToken := extractCSRFToken(t, body) + t.Logf("CsrfToken: %s", validCsrfToken) + + form := url.Values{} + form.Add("email", validEmail) + form.Add("password", validPassword) + form.Add("csrf_token", validCsrfToken) + code, _, _ := ts.postForm(t, "/user/login", form) + t.Logf("Auth result: %d", code) + + code, _, body = ts.get(t, "/snippet/create") + assert.Equal(t, code, http.StatusOK) + assert.StringContains(t, body, "
") + }) +} diff --git a/cmd/web/helpers.go b/cmd/web/helpers.go index 550cee5..fc9395a 100644 --- a/cmd/web/helpers.go +++ b/cmd/web/helpers.go @@ -13,7 +13,13 @@ func (app *application) serverError(w http.ResponseWriter, err error) { trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack()) app.errorLog.Output(2, trace) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + var errorText string + if app.debug { + errorText = trace + } else { + errorText = http.StatusText(http.StatusInternalServerError) + } + http.Error(w, errorText, http.StatusInternalServerError) } func (app *application) clientError(w http.ResponseWriter, status int) { @@ -24,13 +30,13 @@ func (app *application) notFound(w http.ResponseWriter) { app.clientError(w, http.StatusNotFound) } -func (app *application) decodePostForm(r *http.Request, snippetForm any) error { +func (app *application) decodePostForm(r *http.Request, templateForm any) error { err := r.ParseForm() if err != nil { return err } - err = app.formDecoder.Decode(&snippetForm, r.PostForm) + err = app.formDecoder.Decode(&templateForm, r.PostForm) if err != nil { var invalidDecoderError *form.InvalidDecoderError if errors.As(err, &invalidDecoderError) { diff --git a/cmd/web/main.go b/cmd/web/main.go index 203b754..83a5855 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -26,12 +26,14 @@ type application struct { templateCache map[string]*template.Template formDecoder *form.Decoder sessionManager *scs.SessionManager + debug bool } type config struct { addr string staticDir string dsn string + debug bool } func main() { @@ -39,6 +41,7 @@ func main() { flag.StringVar(&cfg.addr, "addr", ":4000", "HTTP network address") flag.StringVar(&cfg.staticDir, "static-dir", "./ui/static", "Path to static assets") flag.StringVar(&cfg.dsn, "dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name") + flag.BoolVar(&cfg.debug, "debug", false, "Option to enable debug mode") flag.Parse() errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile) @@ -68,6 +71,7 @@ func main() { templateCache: templateCache, formDecoder: formDecoder, sessionManager: sessionManager, + debug: cfg.debug, } tlsConfig := &tls.Config{ diff --git a/cmd/web/middleware.go b/cmd/web/middleware.go index 06874bb..b082e43 100644 --- a/cmd/web/middleware.go +++ b/cmd/web/middleware.go @@ -24,9 +24,9 @@ func secureHeaders(next http.Handler) http.Handler { func noSurf(next http.Handler) http.Handler { csrfHanlder := nosurf.New(next) csrfHanlder.SetBaseCookie(http.Cookie{ - HttpOnly: true, + HttpOnly: false, Path: "/", - Secure: true, + Secure: false, }) return csrfHanlder @@ -56,6 +56,7 @@ func (app *application) recoverPanic(next http.Handler) http.Handler { func (app *application) requireAuthentication(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !app.isAuthenticated(r) { + app.sessionManager.Put(r.Context(), "redirectParent", r.URL.Path) http.Redirect(w, r, "/user/login", http.StatusSeeOther) return } diff --git a/cmd/web/routes.go b/cmd/web/routes.go index a891fae..f573e8f 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -24,6 +24,7 @@ func (app *application) routes() http.Handler { dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate) router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home)) + router.Handler(http.MethodGet, "/about", dynamic.ThenFunc(app.about)) router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView)) router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup)) @@ -33,6 +34,9 @@ func (app *application) routes() http.Handler { protected := dynamic.Append(app.requireAuthentication) + router.Handler(http.MethodGet, "/account/view", protected.ThenFunc(app.accountView)) + router.Handler(http.MethodGet, "/account/password", protected.ThenFunc(app.accountChangePassword)) + router.Handler(http.MethodPost, "/account/password", protected.ThenFunc(app.accountChangePasswordPost)) router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate)) router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost)) router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost)) diff --git a/cmd/web/templates.go b/cmd/web/templates.go index d43562f..c62aec2 100644 --- a/cmd/web/templates.go +++ b/cmd/web/templates.go @@ -20,6 +20,7 @@ type templateData struct { Flash string IsAuthenticated bool CSRFToken string + AboutText string } func humanDate(t time.Time) string { diff --git a/internal/models/mocks/users.go b/internal/models/mocks/users.go index 6e1b6b7..17bf352 100644 --- a/internal/models/mocks/users.go +++ b/internal/models/mocks/users.go @@ -29,3 +29,11 @@ func (m *UserModel) Exists(id int) (bool, error) { return false, nil } } + +func (m *UserModel) GetById(id int) (*models.User, error) { + return nil, nil +} + +func (a *UserModel) ChangePassword(id int, curr_pass, new_pass string) error { + return nil +} diff --git a/internal/models/users.go b/internal/models/users.go index 7a61dac..1cff246 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -22,6 +22,8 @@ type UserModelInterface interface { Insert(name, email, password string) error Authenticate(email, password string) (int, error) Exists(id int) (bool, error) + GetById(id int) (*User, error) + ChangePassword(id int, curr_pass, new_pass string) error } type UserModel struct { @@ -82,3 +84,46 @@ func (m *UserModel) Exists(id int) (bool, error) { err := m.DB.QueryRow(stmt, id).Scan(&exists) return exists, err } + +func (a *UserModel) GetById(id int) (*User, error) { + acc := User{} + + stmt := `SELECT name, email, created FROM users WHERE id = ?` + err := a.DB.QueryRow(stmt, id).Scan(&acc.Name, &acc.Email, &acc.Created) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + return &acc, nil +} + +func (a *UserModel) ChangePassword(id int, curr_pass, new_pass string) error { + hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(new_pass), 12) + if err != nil { + return err + } + + var hashedDBPassword []byte + stmt := `SELECT hashed_password FROM users WHERE id = ?` + err = a.DB.QueryRow(stmt, id).Scan(&hashedDBPassword) + if err != nil { + return err + } + err = bcrypt.CompareHashAndPassword(hashedDBPassword, []byte(curr_pass)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return ErrInvalidCredentials + } + return err + } + + stmt = `UPDATE users SET hashed_password = ? WHERE id = ?` + _, err = a.DB.Exec(stmt, hashedNewPassword, id) + if err != nil { + return err + } + return nil +} diff --git a/ui/html/pages/about.tmpl b/ui/html/pages/about.tmpl new file mode 100644 index 0000000..84c50ac --- /dev/null +++ b/ui/html/pages/about.tmpl @@ -0,0 +1,8 @@ +{{define "title"}}About{{end}} + +{{define "main"}} +

About

+
+ {{.AboutText}} +
+{{end}} \ No newline at end of file diff --git a/ui/html/pages/account.tmpl b/ui/html/pages/account.tmpl new file mode 100644 index 0000000..018d349 --- /dev/null +++ b/ui/html/pages/account.tmpl @@ -0,0 +1,28 @@ +{{define "title"}}Account{{end}} + +{{define "main"}} +

Account

+ {{if .Form}} + + + + + + + + + + + + + +
Name{{.Form.Name}}
Email{{.Form.Email}}
Joined{{.Form.Joined}}
+ {{else}} +

There's nothing to see here... yet!

+ {{end}} + +
+ +
+
+{{end}} \ No newline at end of file diff --git a/ui/html/pages/password.tmpl b/ui/html/pages/password.tmpl new file mode 100644 index 0000000..951e10d --- /dev/null +++ b/ui/html/pages/password.tmpl @@ -0,0 +1,35 @@ +{{define "title"}}Change Password{{end}} + +{{define "main"}} +

Change Password

+
+ + {{range .Form.NonFieldErrors}} +
{{.}}
+ {{end}} +
+ + {{with .Form.FieldErrors.curr_pass}} + + {{end}} + +
+
+ + {{with .Form.FieldErrors.new_pass}} + + {{end}} + +
+
+ + {{with .Form.FieldErrors.conf_pass}} + + {{end}} + +
+
+ +
+
+{{end}} \ No newline at end of file diff --git a/ui/html/partials/nav.tmpl b/ui/html/partials/nav.tmpl index 2c06dab..5410cf9 100644 --- a/ui/html/partials/nav.tmpl +++ b/ui/html/partials/nav.tmpl @@ -2,12 +2,14 @@