This commit is contained in:
lbenedar
2026-03-07 17:02:55 +03:00
parent 69658a7bd4
commit 5aea734d2b
14 changed files with 274 additions and 6 deletions

View File

@@ -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)
}

View File

@@ -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, "<form action='/snippet/create' method='POST'>")
})
}

View File

@@ -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) {

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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))

View File

@@ -20,6 +20,7 @@ type templateData struct {
Flash string
IsAuthenticated bool
CSRFToken string
AboutText string
}
func humanDate(t time.Time) string {

View File

@@ -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
}

View File

@@ -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
}

8
ui/html/pages/about.tmpl Normal file
View File

@@ -0,0 +1,8 @@
{{define "title"}}About{{end}}
{{define "main"}}
<h2>About</h2>
<div class='about'>
{{.AboutText}}
</div>
{{end}}

View File

@@ -0,0 +1,28 @@
{{define "title"}}Account{{end}}
{{define "main"}}
<h2>Account</h2>
{{if .Form}}
<table>
<tr>
<th>Name</th>
<th>{{.Form.Name}}</th>
</tr>
<tr>
<th>Email</th>
<th>{{.Form.Email}}</th>
</tr>
<tr>
<th>Joined</th>
<th>{{.Form.Joined}}</th>
</tr>
</table>
{{else}}
<p>There's nothing to see here... yet!</p>
{{end}}
<form action='/account/password' method='GET'>
<div>
<input type='submit' value='Change password'>
</div>
</form>
{{end}}

View File

@@ -0,0 +1,35 @@
{{define "title"}}Change Password{{end}}
{{define "main"}}
<h2>Change Password</h2>
<form action='/account/password' method='POST' novalidate>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{range .Form.NonFieldErrors}}
<div class='error'>{{.}}</div>
{{end}}
<div>
<label>Current password:</label>
{{with .Form.FieldErrors.curr_pass}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='curr_pass' value=''>
</div>
<div>
<label>New password:</label>
{{with .Form.FieldErrors.new_pass}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='new_pass'>
</div>
<div>
<label>Confirm new password:</label>
{{with .Form.FieldErrors.conf_pass}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='conf_pass'>
</div>
<div>
<input type='submit' value='Change password'>
</div>
</form>
{{end}}

View File

@@ -2,12 +2,14 @@
<nav>
<div>
<a href='/'>Home</a>
<a href='/about'>About</a>
{{if .IsAuthenticated}}
<a href='/snippet/create'>Create snippet</a>
{{end}}
</div>
<div>
{{if .IsAuthenticated}}
<a href='/account/view'>Account</a>
<form action='/user/logout' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<button>Logout</button>

1
ui/static/text/about.txt Normal file
View File

@@ -0,0 +1 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus magna quam, eleifend a metus at, tincidunt commodo enim. Integer ac mattis ipsum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas non purus ullamcorper, tristique diam vel, sagittis neque. Fusce quam dolor, accumsan non leo sed, auctor rutrum felis. Duis semper tristique tellus, sed sagittis est lobortis nec. Curabitur porttitor lacus eget semper sodales. Duis diam nisl, maximus eu felis id, placerat pulvinar erat. Etiam elementum elit tellus, at auctor ante accumsan vitae. Nulla elit magna, molestie vitae nibh ut, vehicula accumsan ipsum. Ut id consequat ipsum, volutpat volutpat orci. Aenean rhoncus tellus lacus, nec tempor risus interdum sed. Proin quis quam in metus imperdiet lobortis. Duis euismod sem enim, volutpat mollis tortor posuere sed. Morbi condimentum eget dolor at accumsan.