Compare commits

...

40 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
lbenedar
420b876a7a ch5.6 2026-03-05 12:04:31 +03:00
lbenedar
bdbda690a2 ch5.5 2026-03-05 11:57:45 +03:00
lbenedar
c999dce1fd ch5.4 2026-03-05 11:43:52 +03:00
lbenedar
a5827ae3d7 ch5.3 2026-03-05 11:32:32 +03:00
lbenedar
0f096ff94b ch5.2 2026-03-05 11:16:46 +03:00
lbenedar
dceefea953 ch4.8 2026-03-04 17:25:25 +03:00
lbenedar
c5833c6ddf ch4.7 2026-03-04 17:01:30 +03:00
lbenedar
e5288f0d33 ch4.7 2026-03-04 16:44:25 +03:00
lbenedar
e392f24256 ch4.5 2026-03-04 16:25:10 +03:00
lbenedar
35be6f27ed ch4.4 2026-03-04 16:14:25 +03:00
38 changed files with 1820 additions and 38 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

@@ -1,47 +1,334 @@
package main
import (
"bytes"
"errors"
"fmt"
"html/template"
"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"
)
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) {
ts, ok := app.templateCache[page]
if !ok {
err := fmt.Errorf("the template %s does not exist", page)
app.serverError(w, err)
return
}
buf := new(bytes.Buffer)
err := ts.ExecuteTemplate(buf, "base", data)
if err != nil {
app.serverError(w, err)
return
}
w.WriteHeader(status)
buf.WriteTo(w)
}
func (app *application) home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
app.notFound(w)
return
}
files := []string{
"./ui/html/base.tmpl",
"./ui/html/partials/nav.tmpl",
"./ui/html/pages/home.tmpl",
}
ts, err := template.ParseFiles(files...)
snippets, err := app.snippets.Latest()
if err != nil {
app.serverError(w, err)
return
}
err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
app.serverError(w, err)
}
data := app.newTemplateData(r)
data.Snippets = snippets
app.render(w, http.StatusOK, "home.tmpl", data)
}
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 {
app.notFound(w)
return
}
fmt.Fprintf(w, "Display a specific snippet with ID %d...", id)
snippet, err := app.snippets.Get(id)
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
app.notFound(w)
} else {
app.serverError(w, err)
}
return
}
data := app.newTemplateData(r)
data.Snippet = snippet
app.render(w, http.StatusOK, "view.tmpl", data)
}
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
app.clientError(w, http.StatusMethodNotAllowed)
data := app.newTemplateData(r)
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
}
w.Write([]byte("Create a new snippet"))
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 {
app.serverError(w, err)
return
}
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
import (
"errors"
"fmt"
"net/http"
"runtime/debug"
"github.com/go-playground/form/v4"
)
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) {
@@ -20,3 +29,28 @@ func (app *application) clientError(w http.ResponseWriter, status int) {
func (app *application) notFound(w http.ResponseWriter) {
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,43 +1,105 @@
package main
import (
"crypto/tls"
"database/sql"
"flag"
"html/template"
"log"
"net/http"
"os"
"time"
"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"
)
type application struct {
errorLog *log.Logger
infoLog *log.Logger
errorLog *log.Logger
infoLog *log.Logger
snippets models.SnippetModelInterface
users models.UserModelInterface
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() {
var cfg config
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)
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
db, err := openDB(cfg.dsn)
if err != nil {
errorLog.Fatal(err)
}
defer db.Close()
templateCache, err := newTemplateCache()
if err != nil {
errorLog.Fatal(err)
}
formDecoder := form.NewDecoder()
sessionManager := scs.New()
sessionManager.Store = mysqlstore.New(db)
sessionManager.Lifetime = 12 * time.Hour
app := &application{
errorLog: errorLog,
infoLog: infoLog,
errorLog: errorLog,
infoLog: infoLog,
snippets: &models.SnippetModel{DB: db},
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{
Addr: cfg.addr,
ErrorLog: errorLog,
Handler: app.routes(),
Addr: cfg.addr,
ErrorLog: errorLog,
Handler: app.routes(),
TLSConfig: tlsConfig,
IdleTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
infoLog.Printf("Starting server on %s", cfg.addr)
err := srv.ListenAndServe()
err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
errorLog.Fatal(err)
}
func openDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err = db.Ping(); err != nil {
return nil, err
}
return db, nil
}

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
import "net/http"
import (
"net/http"
func (app *application) routes() *http.ServeMux {
mux := http.NewServeMux()
"gitea.local.lab/Lbenedar/snippetbox/ui"
fileServer := http.FileServer(http.Dir("./ui/static/"))
mux.Handle("/static/", http.StripPrefix("/static", fileServer))
"github.com/julienschmidt/httprouter"
"github.com/justinas/alice"
)
mux.HandleFunc("/", app.home)
mux.HandleFunc("/snippet/view", app.snippetView)
mux.HandleFunc("/snippet/create", app.snippetCreate)
func (app *application) routes() http.Handler {
router := httprouter.New()
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)
}

70
cmd/web/templates.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"html/template"
"io/fs"
"net/http"
"path/filepath"
"time"
"gitea.local.lab/Lbenedar/snippetbox/internal/models"
"gitea.local.lab/Lbenedar/snippetbox/ui"
"github.com/justinas/nosurf"
)
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
Form any
Flash string
IsAuthenticated bool
CSRFToken string
AboutText string
}
func humanDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("02 Jan 2006 at 15:04")
}
var functions = template.FuncMap{
"humanDate": humanDate,
}
func (app *application) newTemplateData(r *http.Request) *templateData {
return &templateData{
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) {
cache := map[string]*template.Template{}
pages, err := fs.Glob(ui.Files, "html/pages/*.tmpl")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
patterns := []string{
"html/base.tmpl",
"html/partials/*.tmpl",
page,
}
ts, err := template.New(name).Funcs(functions).ParseFS(ui.Files, patterns...)
if err != nil {
return nil, err
}
cache[name] = ts
}
return cache, nil
}

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 (
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/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/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/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

@@ -0,0 +1,9 @@
package models
import "errors"
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

@@ -0,0 +1,78 @@
package models
import (
"database/sql"
"errors"
"time"
)
type Snippet struct {
ID int
Title string
Content string
Created 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 {
DB *sql.DB
}
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
stmt := `INSERT INTO snippets (title, content, created, expires)
VALUES (?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? DAY))`
result, err := m.DB.Exec(stmt, title, content, expires)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
if err != nil {
return 0, err
}
return int(id), nil
}
func (m *SnippetModel) Get(id int) (*Snippet, error) {
s := &Snippet{}
stmt := `SELECT id, title, content, created, expires FROM snippets
WHERE expires > UTC_TIMESTAMP() AND id = ?`
err := m.DB.QueryRow(stmt, id).Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoRecord
} else {
return nil, err
}
}
return s, nil
}
func (m *SnippetModel) Latest() ([]*Snippet, error) {
stmt := `SELECT id, title, content, created, expires FROM snippets
WHERE expires > UTC_TIMESTAMP() ORDER BY id DESC LIMIT 10`
rows, err := m.DB.Query(stmt)
if err != nil {
return nil, err
}
defer rows.Close()
snippets := []*Snippet{}
for rows.Next() {
s := &Snippet{}
err = rows.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
if err != nil {
return nil, err
}
snippets = append(snippets, s)
}
if err = rows.Err(); err != nil {
return nil, err
}
return snippets, nil
}

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,9 +14,14 @@
</header>
{{template "nav" .}}
<main>
{{with .Flash}}
<div class='flash'>{{.}}</div>
{{end}}
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
<footer>
Powered by <a href='https://golang.org/'>Go</a> in {{.CurrentYear}}
</footer>
<script src="/static/js/main.js" type="text/javascript"></script>
</body>
</html>

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

@@ -2,5 +2,22 @@
{{define "main"}}
<h2>Latest Snippets</h2>
<p>There's nothing to see here yet!</p>
{{if .Snippets}}
<table>
<tr>
<th>Title</th>
<th>Created</th>
<th>ID</th>
</tr>
{{range .Snippets}}
<tr>
<td><a href='/snippet/view/{{.ID}}'>{{.Title}}</a></td>
<td>{{humanDate .Created}}</td>
<td>#{{.ID}}</td>
</tr>
{{end}}
</table>
{{else}}
<p>There's nothing to see here... yet!</p>
{{end}}
{{end}}

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

18
ui/html/pages/view.tmpl Normal file
View File

@@ -0,0 +1,18 @@
{{define "title"}}Snippet ${{.Snippet.ID}}{{end}}
{{define "main"}}
{{with .Snippet}}
<div class='snippet'>
<div class='metadata'>
<strong>{{.Title}}</strong>
<span>#{{.ID}}</span>
</div>
<pre><code>{{.Content}}</code></pre>
<div class='metadata'>
<time>Created: {{humanDate .Created}}</time>
<time>Expires: {{humanDate .Expires}}</time>
</div>
</div>
{{end}}
{{end}}

View File

@@ -1,5 +1,23 @@
{{define "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>
{{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.