Compare commits

...

30 Commits

Author SHA1 Message Date
lbenedar
5aea734d2b ch17 2026-03-07 17:02:55 +03:00
lbenedar
69658a7bd4 ch14.6-14.7 2026-03-06 19:58:15 +03:00
lbenedar
577f902ab3 ch14.5 2026-03-06 17:59:31 +03:00
lbenedar
ec5ef9a5c1 ch14.4 2026-03-06 17:12:53 +03:00
lbenedar
79d465bd7b ch14.2 2026-03-06 16:46:52 +03:00
lbenedar
c3294de4c6 ch14.2 2026-03-06 16:30:35 +03:00
lbenedar
fc07515dbd ch13.2 2026-03-06 16:05:24 +03:00
lbenedar
8d3c025660 ch13.1 2026-03-06 15:53:59 +03:00
lbenedar
126ccfa715 ch12.2 2026-03-06 15:37:31 +03:00
lbenedar
b6cdb2b860 ch11.7 2026-03-06 14:58:38 +03:00
lbenedar
94345eff97 ch11.6 2026-03-06 14:44:08 +03:00
lbenedar
894d152aae 11.5 2026-03-06 14:05:30 +03:00
lbenedar
dd4c74598d ch11.4 2026-03-06 14:01:57 +03:00
lbenedar
2824e51c29 ch11.3 2026-03-06 12:35:10 +03:00
lbenedar
91f5a63366 ch11.2 2026-03-06 11:48:48 +03:00
lbenedar
6eee11400c ch11.1 2026-03-06 11:34:43 +03:00
lbenedar
f252c2c801 ch10.2_2 2026-03-05 18:12:11 +03:00
lbenedar
3c7cb22298 ch10.2 2026-03-05 18:11:34 +03:00
lbenedar
b644c0384e ch10.1 2026-03-05 17:57:49 +03:00
lbenedar
b50cd42f4f ch9.3 2026-03-05 17:46:26 +03:00
lbenedar
c2883b9916 ch8.5 2026-03-05 15:59:44 +03:00
lbenedar
3bd8a8e1c7 ch8.4 2026-03-05 15:33:47 +03:00
lbenedar
3015b7318e ch8.3 2026-03-05 15:17:19 +03:00
lbenedar
b8fad1a63f ch8.1 2026-03-05 15:08:06 +03:00
lbenedar
8df6f1c6e9 ch8.1 2026-03-05 14:45:31 +03:00
lbenedar
5e98af52fe ch7.2 2026-03-05 14:07:16 +03:00
lbenedar
7ab49c9fc8 ch6.5 2026-03-05 13:24:54 +03:00
lbenedar
be06d0a5ca ch6.4 2026-03-05 13:20:01 +03:00
lbenedar
fbce0c006c ch6.3 2026-03-05 13:04:59 +03:00
lbenedar
fc1484aebe ch6.2 2026-03-05 12:54:29 +03:00
37 changed files with 1583 additions and 50 deletions

5
cmd/web/context.go Normal file
View File

@@ -0,0 +1,5 @@
package main
type contextKey string
const isAuthenticatedContextKey = contextKey("isAuthenticated")

View File

@@ -4,12 +4,51 @@ 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/ui"
"github.com/julienschmidt/httprouter"
) )
type snippetCreateForm struct {
Title string `form:"title"`
Content string `form:"content"`
Expires int `form:"expires"`
validator.Validator `form:"-"`
}
type userSignupForm struct {
Name string `form:"name"`
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
type userLoginForm struct {
Email string `form:"email"`
Password string `form:"password"`
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 {
@@ -46,7 +85,8 @@ func (app *application) home(w http.ResponseWriter, r *http.Request) {
} }
func (app *application) snippetView(w http.ResponseWriter, r *http.Request) { func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.URL.Query().Get("id")) params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.Atoi(params.ByName("id"))
if err != nil || id < 1 { if err != nil || id < 1 {
app.notFound(w) app.notFound(w)
return return
@@ -68,19 +108,227 @@ func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
} }
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) { func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { data := app.newTemplateData(r)
w.Header().Set("Allow", http.MethodPost)
app.clientError(w, http.StatusMethodNotAllowed) data.Form = snippetCreateForm{
Expires: 365,
}
app.render(w, http.StatusOK, "create.tmpl", data)
}
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 return
} }
title := "0 snail"
content := "0 snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n-Kobayshi Issa"
expires := 7
id, err := app.snippets.Insert(title, content, expires) 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 character 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 and 365")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "create.tmpl", data)
return
}
id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
if err != nil { if err != nil {
app.serverError(w, err) app.serverError(w, err)
return return
} }
http.Redirect(w, r, fmt.Sprintf("/snippet/view?id=%d", id), http.StatusSeeOther)
app.sessionManager.Put(r.Context(), "flash", "Snippet successfully created!")
http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
}
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
var form userSignupForm
err := app.decodePostForm(r, &form)
if err != nil {
app.notFound(w)
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 character long")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
return
}
err = app.users.Insert(form.Name, form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrDuplicateEmail) {
form.AddFieldError("email", "Email address already in use")
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
} else {
app.serverError(w, err)
}
return
}
app.sessionManager.Put(r.Context(), "flash", "Your singup was successful. Please log in.")
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
}
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
var form userLoginForm
err := app.decodePostForm(r, &form)
if err != nil {
app.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 address")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
return
}
id, err := app.users.Authenticate(form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrInvalidCredentials) {
form.AddNonFieldError("Email or password is incorrect")
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
} else {
app.serverError(w, err)
}
return
}
err = app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, err)
return
}
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)
}
func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
err := app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, err)
return
}
app.sessionManager.Remove(r.Context(), "authenticatedUserID")
app.sessionManager.Put(r.Context(), "flash", "You've been logged out succefully!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userSignupForm{}
app.render(w, http.StatusOK, "signup.tmpl", data)
}
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userLoginForm{}
app.render(w, http.StatusOK, "login.tmpl", data)
}
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)
} }

228
cmd/web/handlers_test.go Normal file
View File

@@ -0,0 +1,228 @@
package main
import (
"net/http"
"net/url"
"testing"
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
)
func TestPing(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
statusCode, _, body := ts.get(t, "/ping")
assert.Equal(t, statusCode, http.StatusOK)
assert.Equal(t, body, "OK")
}
func TestSnippetView(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
tests := []struct {
name string
urlPath string
wantCode int
wantBody string
}{
{
name: "Valid ID",
urlPath: "/snippet/view/1",
wantCode: http.StatusOK,
wantBody: "An old silent pond...",
},
{
name: "Non-existend ID",
urlPath: "/snippet/view/2",
wantCode: http.StatusNotFound,
},
{
name: "Negative ID",
urlPath: "/snippet/view/-1",
wantCode: http.StatusNotFound,
},
{
name: "Decimal ID",
urlPath: "/snippet/view/1.23",
wantCode: http.StatusNotFound,
},
{
name: "String ID",
urlPath: "/snippet/view/foo",
wantCode: http.StatusNotFound,
},
{
name: "Empty ID",
urlPath: "/snippet/view/",
wantCode: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.get(t, tt.urlPath)
assert.Equal(t, code, tt.wantCode)
if tt.wantBody != "" {
assert.StringContains(t, body, tt.wantBody)
}
})
}
}
func TestUserSignup(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
_, _, body := ts.get(t, "/user/signup")
validCsrfToken := extractCSRFToken(t, body)
t.Logf("CsrfToken: %s", validCsrfToken)
const (
validName = "Bob"
validPassword = "validPa$$word"
validEmail = "bob@example.com"
formTag = "<form action='/user/signup' method='POST' novalidate>"
)
tests := []struct {
name string
userName string
userEmail string
userPassword string
csrfToken string
wantCode int
wantFormTag string
}{
{
name: "Valid submission",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCsrfToken,
wantCode: http.StatusSeeOther,
},
{
name: "Invalid CSRF Token",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: "wrongToken",
wantCode: http.StatusBadRequest,
},
{
name: "Empty name",
userName: "",
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCsrfToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty email",
userName: validName,
userEmail: "",
userPassword: validPassword,
csrfToken: validCsrfToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty password",
userName: validName,
userEmail: validEmail,
userPassword: "",
csrfToken: validCsrfToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Invalid email",
userName: validName,
userEmail: "bob@example.",
userPassword: validPassword,
csrfToken: validCsrfToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Short password",
userName: validName,
userEmail: validEmail,
userPassword: "pa$$",
csrfToken: validCsrfToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Duplicate email",
userName: validName,
userEmail: "dupe@example.com",
userPassword: validPassword,
csrfToken: validCsrfToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
form := url.Values{}
form.Add("name", tt.userName)
form.Add("email", tt.userEmail)
form.Add("password", tt.userPassword)
form.Add("csrf_token", tt.csrfToken)
code, _, body := ts.postForm(t, "/user/signup", form)
assert.Equal(t, code, tt.wantCode)
if tt.wantFormTag != "" {
assert.StringContains(t, body, tt.wantFormTag)
}
})
}
}
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

@@ -1,16 +1,25 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
"github.com/go-playground/form/v4"
) )
func (app *application) serverError(w http.ResponseWriter, err error) { 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) {
@@ -20,3 +29,28 @@ func (app *application) clientError(w http.ResponseWriter, status int) {
func (app *application) notFound(w http.ResponseWriter) { func (app *application) notFound(w http.ResponseWriter) {
app.clientError(w, http.StatusNotFound) app.clientError(w, http.StatusNotFound)
} }
func (app *application) decodePostForm(r *http.Request, templateForm any) error {
err := r.ParseForm()
if err != nil {
return err
}
err = app.formDecoder.Decode(&templateForm, r.PostForm)
if err != nil {
var invalidDecoderError *form.InvalidDecoderError
if errors.As(err, &invalidDecoderError) {
panic("Invalid decoder error")
}
return err
}
return nil
}
func (app *application) isAuthenticated(r *http.Request) bool {
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
return false
}
return isAuthenticated
}

View File

@@ -1,29 +1,39 @@
package main package main
import ( import (
"crypto/tls"
"database/sql" "database/sql"
"flag" "flag"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"os" "os"
"time"
"gitea.local.lab/Lbenedar/snippetbox/internal/models" "gitea.local.lab/Lbenedar/snippetbox/internal/models"
"github.com/alexedwards/scs/mysqlstore"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
) )
type application struct { type application struct {
errorLog *log.Logger errorLog *log.Logger
infoLog *log.Logger infoLog *log.Logger
snippets *models.SnippetModel snippets models.SnippetModelInterface
templateCache map[string]*template.Template users models.UserModelInterface
templateCache map[string]*template.Template
formDecoder *form.Decoder
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() {
@@ -31,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)
@@ -47,21 +58,38 @@ func main() {
errorLog.Fatal(err) errorLog.Fatal(err)
} }
formDecoder := form.NewDecoder()
sessionManager := scs.New()
sessionManager.Store = mysqlstore.New(db)
sessionManager.Lifetime = 12 * time.Hour
app := &application{ app := &application{
errorLog: errorLog, errorLog: errorLog,
infoLog: infoLog, infoLog: infoLog,
snippets: &models.SnippetModel{DB: db}, snippets: &models.SnippetModel{DB: db},
templateCache: templateCache, users: &models.UserModel{DB: db},
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
debug: cfg.debug,
}
tlsConfig := &tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, // TODO: read about CurvePreferences
} }
srv := &http.Server{ srv := &http.Server{
Addr: cfg.addr, Addr: cfg.addr,
ErrorLog: errorLog, ErrorLog: errorLog,
Handler: app.routes(), Handler: app.routes(),
TLSConfig: tlsConfig,
IdleTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
} }
infoLog.Printf("Starting server on %s", cfg.addr) infoLog.Printf("Starting server on %s", cfg.addr)
err = srv.ListenAndServe() err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
errorLog.Fatal(err) errorLog.Fatal(err)
} }

88
cmd/web/middleware.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"context"
"fmt"
"net/http"
"github.com/justinas/nosurf"
)
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com")
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "deny")
w.Header().Set("X-Frame-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "0")
next.ServeHTTP(w, r)
})
}
func noSurf(next http.Handler) http.Handler {
csrfHanlder := nosurf.New(next)
csrfHanlder.SetBaseCookie(http.Cookie{
HttpOnly: false,
Path: "/",
Secure: false,
})
return csrfHanlder
}
func (app *application) logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.infoLog.Printf("%s - %s %s %s", r.RemoteAddr, r.Proto, r.Method, r.URL.RequestURI())
next.ServeHTTP(w, r)
})
}
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
app.serverError(w, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
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
}
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
if id == 0 {
next.ServeHTTP(w, r)
return
}
exists, err := app.users.Exists(id)
if err != nil {
app.serverError(w, err)
return
}
if exists {
ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,46 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
)
func TestSecureHeaders(t *testing.T) {
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
secureHeaders(next).ServeHTTP(rr, r)
rs := rr.Result()
expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
assert.Equal(t, rs.Header.Get("Content-Security-Policy"), expectedValue)
expectedValue = "nosniff"
assert.Equal(t, rs.Header.Get("X-Frame-Options"), expectedValue)
expectedValue = "0"
assert.Equal(t, rs.Header.Get("X-XSS-Protection"), expectedValue)
assert.Equal(t, rs.StatusCode, http.StatusOK)
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}

View File

@@ -1,16 +1,46 @@
package main package main
import "net/http" import (
"net/http"
func (app *application) routes() *http.ServeMux { "gitea.local.lab/Lbenedar/snippetbox/ui"
mux := http.NewServeMux()
fileServer := http.FileServer(http.Dir("./ui/static/")) "github.com/julienschmidt/httprouter"
mux.Handle("/static/", http.StripPrefix("/static", fileServer)) "github.com/justinas/alice"
)
mux.HandleFunc("/", app.home) func (app *application) routes() http.Handler {
mux.HandleFunc("/snippet/view", app.snippetView) router := httprouter.New()
mux.HandleFunc("/snippet/create", app.snippetCreate)
return mux router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.notFound(w)
})
fileServer := http.FileServer(http.FS(ui.Files))
router.Handler(http.MethodGet, "/static/*filepath", fileServer)
router.HandlerFunc(http.MethodGet, "/ping", ping)
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))
router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
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))
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
return standard.Then(router)
} }

View File

@@ -2,21 +2,32 @@ package main
import ( import (
"html/template" "html/template"
"io/fs"
"net/http" "net/http"
"path/filepath" "path/filepath"
"time" "time"
"gitea.local.lab/Lbenedar/snippetbox/internal/models" "gitea.local.lab/Lbenedar/snippetbox/internal/models"
"gitea.local.lab/Lbenedar/snippetbox/ui"
"github.com/justinas/nosurf"
) )
type templateData struct { type templateData struct {
CurrentYear int CurrentYear int
Snippet *models.Snippet Snippet *models.Snippet
Snippets []*models.Snippet Snippets []*models.Snippet
Form any
Flash string
IsAuthenticated bool
CSRFToken string
AboutText string
} }
func humanDate(t time.Time) string { func humanDate(t time.Time) string {
return t.Format("02 Jan 2006 at 15:04") if t.IsZero() {
return ""
}
return t.UTC().Format("02 Jan 2006 at 15:04")
} }
var functions = template.FuncMap{ var functions = template.FuncMap{
@@ -25,31 +36,30 @@ var functions = template.FuncMap{
func (app *application) newTemplateData(r *http.Request) *templateData { func (app *application) newTemplateData(r *http.Request) *templateData {
return &templateData{ return &templateData{
CurrentYear: time.Now().Year(), CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r),
} }
} }
func newTemplateCache() (map[string]*template.Template, error) { func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{} cache := map[string]*template.Template{}
pages, err := filepath.Glob("./ui/html/pages/*.tmpl") pages, err := fs.Glob(ui.Files, "html/pages/*.tmpl")
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, page := range pages { for _, page := range pages {
name := filepath.Base(page) name := filepath.Base(page)
ts, err := template.New(name).Funcs(functions).ParseFiles("./ui/html/base.tmpl")
if err != nil {
return nil, err
}
ts, err = ts.ParseGlob("./ui/html/partials/nav.tmpl") patterns := []string{
if err != nil { "html/base.tmpl",
return nil, err "html/partials/*.tmpl",
page,
} }
ts, err := template.New(name).Funcs(functions).ParseFS(ui.Files, patterns...)
ts, err = ts.ParseFiles(page)
if err != nil { if err != nil {
return nil, err return nil, err
} }

39
cmd/web/templates_test.go Normal file
View File

@@ -0,0 +1,39 @@
package main
import (
"testing"
"time"
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
)
func TestHumanDate(t *testing.T) {
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
want: "17 Mar 2022 at 10:15",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2022 at 09:15",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
assert.Equal(t, hd, tt.want)
})
}
}

104
cmd/web/testutils_test.go Normal file
View File

@@ -0,0 +1,104 @@
package main
import (
"bytes"
"html"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"testing"
"time"
"gitea.local.lab/Lbenedar/snippetbox/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`)
func extractCSRFToken(t *testing.T, body string) string {
t.Logf("Body: %s", body)
matches := csrfTokenRX.FindStringSubmatch(body)
if len(matches) < 2 {
t.Fatal("no csrf token found in body")
}
return html.UnescapeString(string(matches[1]))
}
func newTestApplication(t *testing.T) *application {
templateCache, err := newTemplateCache()
if err != nil {
t.Fatal(err)
}
formDecoder := form.NewDecoder()
sessionManager := scs.New()
sessionManager.Lifetime = 12 * time.Hour
sessionManager.Cookie.Secure = true
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
snippets: &mocks.SnippetModel{},
users: &mocks.UserModel{},
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
}
}
type testServer struct {
*httptest.Server
}
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
ts.Client().Jar = jar
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &testServer{ts}
}
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
rs, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}

7
go.mod
View File

@@ -4,5 +4,12 @@ go 1.25.0
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/alexedwards/scs/mysqlstore v0.0.0-20251002162104-209de6e426de // indirect
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/go-playground/form/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/julienschmidt/httprouter v1.3.0 // indirect
github.com/justinas/alice v1.2.0 // indirect
github.com/justinas/nosurf v1.2.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
) )

15
go.sum
View File

@@ -1,4 +1,19 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alexedwards/scs/mysqlstore v0.0.0-20251002162104-209de6e426de h1:/Y/iIFgV1Ofvk4Euv5gUQ74vgqFZOQ1wlJQ3yz/zYGs=
github.com/alexedwards/scs/mysqlstore v0.0.0-20251002162104-209de6e426de/go.mod h1:p8jK3D80sw1PFrCSdlcJF1O75bp55HqbgDyyCLM0FrE=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk=
github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=

30
internal/assert/assert.go Normal file
View File

@@ -0,0 +1,30 @@
package assert
import (
"strings"
"testing"
)
func Equal[T comparable](t *testing.T, actual, expected T) {
t.Helper()
if actual != expected {
t.Errorf("got: %v; want: %v", actual, expected)
}
}
func StringContains(t *testing.T, actual, expectedString string) {
t.Helper()
if !strings.Contains(actual, expectedString) {
t.Errorf("got: %q; expected to contain: %q", actual, expectedString)
}
}
func NilError(t *testing.T, actual error) {
t.Helper()
if actual != nil {
t.Errorf("got: %v; expected: nil", actual)
}
}

View File

@@ -2,4 +2,8 @@ package models
import "errors" import "errors"
var ErrNoRecord = errors.New("models: no matching record found") var (
ErrNoRecord = errors.New("models: no matching record found")
ErrInvalidCredentials = errors.New("models: invalid credentials")
ErrDuplicateEmail = errors.New("models: duplicate email")
)

View File

@@ -0,0 +1,34 @@
package mocks
import (
"time"
"gitea.local.lab/Lbenedar/snippetbox/internal/models"
)
var mockSnippet = &models.Snippet{
ID: 1,
Title: "An old silent pond",
Content: "An old silent pond...",
Created: time.Now(),
Expires: time.Now(),
}
type SnippetModel struct{}
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
return 2, nil
}
func (m *SnippetModel) Get(id int) (*models.Snippet, error) {
switch id {
case 1:
return mockSnippet, nil
default:
return nil, models.ErrNoRecord
}
}
func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
return []*models.Snippet{mockSnippet}, nil
}

View File

@@ -0,0 +1,39 @@
package mocks
import "gitea.local.lab/Lbenedar/snippetbox/internal/models"
type UserModel struct{}
func (m *UserModel) Insert(name, email, password string) error {
switch email {
case "dupe@example.com":
return models.ErrDuplicateEmail
default:
return nil
}
}
func (m *UserModel) Authenticate(email, password string) (int, error) {
if email == "alice@example.com" && password == "pa$$word" {
return 1, nil
}
return 0, models.ErrInvalidCredentials
}
func (m *UserModel) Exists(id int) (bool, error) {
switch id {
case 1:
return true, nil
default:
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

@@ -13,7 +13,11 @@ type Snippet struct {
Created time.Time Created time.Time
Expires time.Time Expires time.Time
} }
type SnippetModelInterface interface {
Insert(title string, content string, expires int) (int, error)
Get(id int) (*Snippet, error)
Latest() ([]*Snippet, error)
}
type SnippetModel struct { type SnippetModel struct {
DB *sql.DB DB *sql.DB
} }

26
internal/models/testdata/setup.sql vendored Normal file
View File

@@ -0,0 +1,26 @@
CREATE TABLE snippets (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created DATETIME NOT NULL,
expires DATETIME NOT NULL
);
CREATE INDEX idx_snippets_created ON snippets(created);
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
hashed_password CHAR(60) NOT NULL,
created DATETIME NOT NULL
);
ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);
INSERT INTO users (name, email, hashed_password, created) VALUES (
'Alice Jones',
'alice@example.com',
'$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG',
'2022-01-01 10:00:00'
);

3
internal/models/testdata/teardown.sql vendored Normal file
View File

@@ -0,0 +1,3 @@
DROP TABLE users;
DROP TABLE snippets;

View File

@@ -0,0 +1,37 @@
package models
import (
"database/sql"
"os"
"testing"
)
func newTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("mysql", "test_web:pass@/test_snippetbox?parseTime=true&multiStatements=true")
if err != nil {
t.Fatal(err)
}
script, err := os.ReadFile("./testdata/setup.sql")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(string(script))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
script, err := os.ReadFile("./testdata/teardown.sql")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(string(script))
if err != nil {
t.Fatal(err)
}
db.Close()
})
return db
}

129
internal/models/users.go Normal file
View File

@@ -0,0 +1,129 @@
package models
import (
"database/sql"
"errors"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int
Name string
Email string
HashedPassword []byte
Created time.Time
}
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 {
DB *sql.DB
}
func (m *UserModel) Insert(name, email, password string) error {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
stmt := `INSERT INTO users (name, email, hashed_password, created)
VALUES(?, ?, ?, UTC_TIMESTAMP())`
_, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
if err != nil {
var mySQLError *mysql.MySQLError
if errors.As(err, &mySQLError) {
if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
return ErrDuplicateEmail
}
}
return err
}
return nil
}
func (m *UserModel) Authenticate(email, password string) (int, error) {
var id int
var hashedPassword []byte
stmt := `SELECT id, hashed_password FROM users WHERE email = ?`
err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrInvalidCredentials
}
return 0, err
}
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return 0, ErrInvalidCredentials
}
return 0, err
}
return id, nil
}
func (m *UserModel) Exists(id int) (bool, error) {
var exists bool
stmt := `SELECT EXISTS(SELECT true FROM users WHERE id = ?)`
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
}

View File

@@ -0,0 +1,45 @@
package models
import (
"testing"
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
)
func TestUserModelExists(t *testing.T) {
if testing.Short() {
t.Skip("models: skipping integration test")
}
tests := []struct {
name string
userID int
want bool
}{
{
name: "Valid ID",
userID: 1,
want: true,
},
{
name: "Zero ID",
userID: 0,
want: false,
},
{
name: "Non-existent ID",
userID: 2,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := newTestDB(t)
m := UserModel{DB: db}
exists, err := m.Exists(tt.userID)
assert.Equal(t, exists, tt.want)
assert.NilError(t, err)
})
}
}

View File

@@ -0,0 +1,62 @@
package validator
import (
"regexp"
"strings"
"unicode/utf8"
)
type Validator struct {
NonFieldErrors []string
FieldErrors map[string]string
}
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
}
func (v *Validator) AddFieldError(key, message string) {
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}
if _, exist := v.FieldErrors[key]; !exist {
v.FieldErrors[key] = message
}
}
func (v *Validator) AddNonFieldError(message string) {
v.NonFieldErrors = append(v.NonFieldErrors, message)
}
func (v *Validator) CheckField(ok bool, key, message string) {
if !ok {
v.AddFieldError(key, message)
}
}
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(value) <= n
}
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
for i := range permittedValues {
if value == permittedValues[i] {
return true
}
}
return false
}
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
func MinChars(value string, n int) bool {
return utf8.RuneCountInString(value) >= n
}
func Matches(value string, rx *regexp.Regexp) bool {
return rx.MatchString(value)
}

18
tls/cert.pem Normal file
View File

@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC+TCCAeGgAwIBAgIQcOLtQY/t+q3a9ve2Lk3fmDANBgkqhkiG9w0BAQsFADAS
MRAwDgYDVQQKEwdBY21lIENvMB4XDTI2MDMwNTE0NTcyM1oXDTI3MDMwNTE0NTcy
M1owEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBALfXzj8grHwtMacXPpvyXWjhiHhPGQRwruGUWDTx0S32Zz/oNakkYaAE
Sz++MxAJiu4E0mhCc0XFfwurMU1kJqQO8kZVAvd2d3HkpgX+PXuShYOXZoUaNC2H
FdjmyYLrNMSDVPF5krotwaG553zFNMsOo+WYecqS6LtllhBGvuw+xIKZ7RJGKPD5
Stc5ejmhz+pVq3q3Fm/0caBlCXUxI2aqCDra2sI/glgPKFF1xYWtwSu5iFZggpRf
1jWblKA8Jir7fnDQKKdtY3Nbhj/QeDdHUeFWM29igZWO459O7rI4SEafCPRznsf6
V1IKdMq1loo1hPGKmaK/gt3XZOph7scCAwEAAaNLMEkwDgYDVR0PAQH/BAQDAgWg
MBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwFAYDVR0RBA0wC4IJ
bG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQBsFR9RwmLaNTjKfy7svedx/Zgy
GSf+fAlzoWk8CMoCDWkrdJsi8iYmAcRPJbm8i8y5pXLR1vKMa3zmxsFcw0qpkRSu
pnCRlixbml7gnuyYC5SMXfF8cFS5IGNm6U7hKP+wDoARGXhJWWs3qjrneEgGiOfk
GSM2fsM9aoehbj5Cv69f87hk96arWD5b3vLedZafNpX+ZpM9OTh+NKZzbAhyAHw5
/8hyHi96YOpzTDLqj4IQ3DH3yA32vP3DjnjHH1LUF9W2POGIZZ+o8GHmL6m/2ltX
W+7SmACujCU5RY93cLmH7liUtqBxOUvmN8uAMXnpdxU8KcYFmglTFT9o083S
-----END CERTIFICATE-----

28
tls/key.pem Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC3184/IKx8LTGn
Fz6b8l1o4Yh4TxkEcK7hlFg08dEt9mc/6DWpJGGgBEs/vjMQCYruBNJoQnNFxX8L
qzFNZCakDvJGVQL3dndx5KYF/j17koWDl2aFGjQthxXY5smC6zTEg1TxeZK6LcGh
ued8xTTLDqPlmHnKkui7ZZYQRr7sPsSCme0SRijw+UrXOXo5oc/qVat6txZv9HGg
ZQl1MSNmqgg62trCP4JYDyhRdcWFrcEruYhWYIKUX9Y1m5SgPCYq+35w0CinbWNz
W4Y/0Hg3R1HhVjNvYoGVjuOfTu6yOEhGnwj0c57H+ldSCnTKtZaKNYTxipmiv4Ld
12TqYe7HAgMBAAECggEAF2y1eGCy6Z2ekoJJNXijw03dZCr77o9nERIkTRa0xwz6
6e8uCy+6CrgRWH1lJzl4DMzrfGJfKrg38GYvQYOt0sCeySxi1OIw5P+z0darwdeR
EIvVgcctd9GbDIiXi4lpr1Jmm+AYPIXBAtjbsI+2IttqMcKEXjXq1pnijY9eG17K
eSjIF+/JomBXqUGB2/+ySAdPDJckqdXKtVklQFmkfacVX5Xv1nbU5uC0LkRs0GkE
Y1PKy2V/TddBynS+lyesmWDLCTHVUBp2NjD5Y0tfHDzo50Xog8rFcargK4phembA
Xkqe/D2rRFq9nSfpwboVvb22+wSD+gbOSfYdDNnN3QKBgQDDk/aZ9+cxIBJbaZdd
48Gwa9Qeh6n0lMDGNVt2UIJlczA5nqy/95BaNmI71cEoVCevSNItR8BLZcDQhv0R
hZ7J7lt3L7bPB6aShm+61np6iQKg+vsrK2qU6k9FKU4YRVgpwGFd10jQvE0fDISp
d80WQazRiaRCV4kSNJb82y3C4wKBgQDwo7xJcH1cs+LXZ9V871t2Vo6zMmnBDtRP
ZbUkjIFYFavWl0vQQK1eENth5luB5glRaX9BsrkWYl45k964sfmJo1QvmV01c3Qn
1TujTdgWEJ1oWERScu0MpVpDx9Moh/A8iT7e8Y2Yp+8xvq17cXKA/mg2jHk31q1k
8SkD0/LVzQKBgCBCdbir1Wya96wwXJFWEgrBnnlysyvupWWMQ4ved8O4HkpCzAfW
E+9tbQKlnXjDeNBG2LQzU5qcLBO5UGDlg22XbWrZafP4NReSKTfOTOGNW+ulumxC
exAZHf4wc/s45PPuEaFi81XK2YW3kOJLKn7zUkg1xexTd/6SwhzvIjs1AoGAKjb/
K/8A7wdbryA3EpDHAc6TgBpC9SxN0JPuIDhJ5JMAr0ehdCo0f8EDS3xm5zXcwpx4
R6U71RJypzUqqEoIlb3CYgtMj7juUqKUsMRSOSS3CHwbmD8zGLnVSPjAMWcPnP8S
uXe0uGgVjSt/MZ2oUbrPu2oPRd1yU7f+0v4Wpq0CgYAdSyFoODxnd0gJ/U3EwZCM
jNDuxjZzw5qQmM03sZposzUQaccd3cyw53qp5PuTOFp1V8Y//NrWmrA7Ti57fx3K
Zn8gXsmNwuNcbTf7KfVsVvLm9K+vi0hZvQDFLsoZ5Q3aeD6vh7W9PwnObRSCc/9g
qgGzmZavUnaNukwkFyyJew==
-----END PRIVATE KEY-----

8
ui/efs.go Normal file
View File

@@ -0,0 +1,8 @@
package ui
import (
"embed"
)
//go:embed "html" "static"
var Files embed.FS

View File

@@ -14,6 +14,9 @@
</header> </header>
{{template "nav" .}} {{template "nav" .}}
<main> <main>
{{with .Flash}}
<div class='flash'>{{.}}</div>
{{end}}
{{template "main" .}} {{template "main" .}}
</main> </main>
<footer> <footer>

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

33
ui/html/pages/create.tmpl Normal file
View File

@@ -0,0 +1,33 @@
{{define "title"}}Create a New Snippet{{end}}
{{define "main"}}
<form action='/snippet/create' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<div>
<label>Title:</label>
{{with .Form.FieldErrors.title}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='title' value='{{.Form.Title}}'>
</div>
<div>
<label>Content:</label>
{{with .Form.FieldErrors.content}}
<label class='error'>{{.}}</label>
{{end}}
<textarea name='content'>{{.Form.Content}}</textarea>
</div>
<div>
<label>Delete in:</label>
{{with .Form.FieldErrors.expires}}
<label class='error'>{{.}}</label>
{{end}}
<input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
<input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
<input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
</div>
<div>
<input type='submit' value='Publish snippet'>
</div>
</form>
{{end}}

View File

@@ -11,7 +11,7 @@
</tr> </tr>
{{range .Snippets}} {{range .Snippets}}
<tr> <tr>
<td><a href='/snippet/view?id={{.ID}}'>{{.Title}}</a></td> <td><a href='/snippet/view/{{.ID}}'>{{.Title}}</a></td>
<td>{{humanDate .Created}}</td> <td>{{humanDate .Created}}</td>
<td>#{{.ID}}</td> <td>#{{.ID}}</td>
</tr> </tr>

27
ui/html/pages/login.tmpl Normal file
View File

@@ -0,0 +1,27 @@
{{define "title"}}Login{{end}}
{{define "main"}}
<form action='/user/login' method='POST' novalidate>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{range .Form.NonFieldErrors}}
<div class='error'>{{.}}</div>
{{end}}
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Login'>
</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}}

31
ui/html/pages/signup.tmpl Normal file
View File

@@ -0,0 +1,31 @@
{{define "title"}}Signup{{end}}
{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<div>
<label>Name:</label>
{{with .Form.FieldErrors.name}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='name' value='{{.Form.Name}}'>
</div>
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Signup'>
</div>
</form>
{{end}}

View File

@@ -1,5 +1,23 @@
{{define "nav"}} {{define "nav"}}
<nav> <nav>
<a href='/'>Home</a> <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>
</form>
{{else}}
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
{{end}}
</div>
</nav> </nav>
{{end}} {{end}}

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.