diff --git a/go.mod b/go.mod index 699c043..86af9c4 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.25.0 require ( github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/lib/pq v1.11.2 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 4d218cc..8aa2d87 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,7 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/internal/data/models.go b/internal/data/models.go index 293edbb..a46ba71 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -11,23 +11,30 @@ var ( ) type Models struct { - Movies interface { - Insert(movie *Movie) error - Get(id int64) (*Movie, error) - Update(movie *Movie) error - Delete(id int64) error - GetAll(title string, genres []string, filter Filters) ([]*Movie, Metadata, error) - } + Movies MovieModel + Users UserModel } +// type Models struct { +// Movies interface { +// Insert(movie *Movie) error +// Get(id int64) (*Movie, error) +// Update(movie *Movie) error +// Delete(id int64) error +// GetAll(title string, genres []string, filter Filters) ([]*Movie, Metadata, error) +// } +// } + func NewModels(db *sql.DB) Models { return Models{ Movies: MovieModel{DB: db}, + Users: UserModel{DB: db}, } } -func NewMockModels() Models { - return Models{ - Movies: MockMovieModel{}, - } -} +// func NewMockModels() Models { +// return Models{ +// Movies: MockMovieModel{}, +// Users: MockMovieModel{}, +// } +// } diff --git a/internal/data/users.go b/internal/data/users.go new file mode 100644 index 0000000..7779022 --- /dev/null +++ b/internal/data/users.go @@ -0,0 +1,171 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "time" + + "gitea.local.lab/Lbenedar/greenlight/internal/validator" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrDuplicateEmail = errors.New("duplicate email") +) + +type User struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Email string `json:"email"` + Password password `json:"-"` + Activated bool `json:"activated"` + Version int `json:"-"` +} + +type password struct { + plaintext *string + hash []byte +} + +func (p *password) Set(plaintextPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return err + } + + p.plaintext = &plaintextPassword + p.hash = hash + + return nil +} + +func (p *password) Matches(plaintextPassword string) (bool, error) { + err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) + if err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return false, nil + default: + return false, err + } + } + return true, nil +} + +func ValidateEmail(v *validator.Validator, email string) { + v.Check(email != "", "email", "must be provided") + v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address") +} + +func ValidatePasswordPlaintext(v *validator.Validator, password string) { + v.Check(password != "", "password", "must be provided") + v.Check(len(password) >= 8, "password", "must be at least 8 bytes long") + v.Check(len(password) <= 72, "password", "must not be more than 72 bytes long") +} + +func ValidateUser(v *validator.Validator, user *User) { + v.Check(user.Name != "", "name", "must be provided") + v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") + + ValidateEmail(v, user.Email) + + if user.Password.plaintext != nil { + ValidatePasswordPlaintext(v, *user.Password.plaintext) + } + + if user.Password.hash != nil { + panic("missing password hash for user") + } +} + +type UserModel struct { + DB *sql.DB +} + +func (m UserModel) Insert(user *User) error { + query := ` + INSERT INTO users (name, email, password_hash, activated) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, version` + + args := []any{user.Name, user.Email, user.Password.hash, user.Activated} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version) + if err != nil { + switch { + case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: + return ErrDuplicateEmail + default: + return err + } + } + return nil +} + +func (m UserModel) GetByEmail(email string) (*User, error) { + query := ` + SELECT id, created_at, name, email, password_hash, activated, version + FROM users + WHERE email = $1` + + var user User + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, email).Scan( + &user.ID, + &user.CreatedAt, + &user.Name, + &user.Email, + &user.Password.hash, + &user.Activated, + &user.Version, + ) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrRecordNotFound + default: + return nil, err + } + } + + return &user, nil +} + +func (m UserModel) Update(user *User) error { + query := ` + UPDATE users + SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1 + WHERE id = $5 AND version = $6 + RETURNING version` + + args := []any{ + user.Name, + user.Email, + user.Password.hash, + user.Activated, + user.ID, + user.Version, + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version) + if err != nil { + switch { + case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: + return ErrDuplicateEmail + default: + return err + } + } + return nil +}