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" "bytes"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"gitea.local.lab/Lbenedar/snippetbox/internal/models" "gitea.local.lab/Lbenedar/snippetbox/internal/models"
"gitea.local.lab/Lbenedar/snippetbox/internal/validator" "gitea.local.lab/Lbenedar/snippetbox/internal/validator"
"gitea.local.lab/Lbenedar/snippetbox/ui"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
) )
@@ -32,6 +35,20 @@ type userLoginForm struct {
validator.Validator `form:"-"` 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) { func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) {
ts, ok := app.templateCache[page] ts, ok := app.templateCache[page]
if !ok { if !ok {
@@ -210,7 +227,11 @@ func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
} }
app.sessionManager.Put(r.Context(), "authenticatedUserID", id) 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) 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) { func ping(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK")) 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()) trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack())
app.errorLog.Output(2, trace) 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) { 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) 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() err := r.ParseForm()
if err != nil { if err != nil {
return err return err
} }
err = app.formDecoder.Decode(&snippetForm, r.PostForm) err = app.formDecoder.Decode(&templateForm, r.PostForm)
if err != nil { if err != nil {
var invalidDecoderError *form.InvalidDecoderError var invalidDecoderError *form.InvalidDecoderError
if errors.As(err, &invalidDecoderError) { if errors.As(err, &invalidDecoderError) {

View File

@@ -26,12 +26,14 @@ type application struct {
templateCache map[string]*template.Template templateCache map[string]*template.Template
formDecoder *form.Decoder formDecoder *form.Decoder
sessionManager *scs.SessionManager sessionManager *scs.SessionManager
debug bool
} }
type config struct { type config struct {
addr string addr string
staticDir string staticDir string
dsn string dsn string
debug bool
} }
func main() { func main() {
@@ -39,6 +41,7 @@ func main() {
flag.StringVar(&cfg.addr, "addr", ":4000", "HTTP network address") flag.StringVar(&cfg.addr, "addr", ":4000", "HTTP network address")
flag.StringVar(&cfg.staticDir, "static-dir", "./ui/static", "Path to static assets") 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.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() flag.Parse()
errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile) errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
@@ -68,6 +71,7 @@ func main() {
templateCache: templateCache, templateCache: templateCache,
formDecoder: formDecoder, formDecoder: formDecoder,
sessionManager: sessionManager, sessionManager: sessionManager,
debug: cfg.debug,
} }
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{

View File

@@ -24,9 +24,9 @@ func secureHeaders(next http.Handler) http.Handler {
func noSurf(next http.Handler) http.Handler { func noSurf(next http.Handler) http.Handler {
csrfHanlder := nosurf.New(next) csrfHanlder := nosurf.New(next)
csrfHanlder.SetBaseCookie(http.Cookie{ csrfHanlder.SetBaseCookie(http.Cookie{
HttpOnly: true, HttpOnly: false,
Path: "/", Path: "/",
Secure: true, Secure: false,
}) })
return csrfHanlder return csrfHanlder
@@ -56,6 +56,7 @@ func (app *application) recoverPanic(next http.Handler) http.Handler {
func (app *application) requireAuthentication(next http.Handler) http.Handler { func (app *application) requireAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !app.isAuthenticated(r) { if !app.isAuthenticated(r) {
app.sessionManager.Put(r.Context(), "redirectParent", r.URL.Path)
http.Redirect(w, r, "/user/login", http.StatusSeeOther) http.Redirect(w, r, "/user/login", http.StatusSeeOther)
return return
} }

View File

@@ -24,6 +24,7 @@ func (app *application) routes() http.Handler {
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate) dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home)) 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, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup)) 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) 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.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost)) router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost)) router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))

View File

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

View File

@@ -29,3 +29,11 @@ func (m *UserModel) Exists(id int) (bool, error) {
return false, nil 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 Insert(name, email, password string) error
Authenticate(email, password string) (int, error) Authenticate(email, password string) (int, error)
Exists(id int) (bool, error) Exists(id int) (bool, error)
GetById(id int) (*User, error)
ChangePassword(id int, curr_pass, new_pass string) error
} }
type UserModel struct { type UserModel struct {
@@ -82,3 +84,46 @@ func (m *UserModel) Exists(id int) (bool, error) {
err := m.DB.QueryRow(stmt, id).Scan(&exists) err := m.DB.QueryRow(stmt, id).Scan(&exists)
return exists, err 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> <nav>
<div> <div>
<a href='/'>Home</a> <a href='/'>Home</a>
<a href='/about'>About</a>
{{if .IsAuthenticated}} {{if .IsAuthenticated}}
<a href='/snippet/create'>Create snippet</a> <a href='/snippet/create'>Create snippet</a>
{{end}} {{end}}
</div> </div>
<div> <div>
{{if .IsAuthenticated}} {{if .IsAuthenticated}}
<a href='/account/view'>Account</a>
<form action='/user/logout' method='POST'> <form action='/user/logout' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'> <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<button>Logout</button> <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.