feature: 初始化项目

This commit is contained in:
梁真铭 2025-01-02 11:08:45 +08:00
parent b1027ef885
commit 2ca213e37e
29 changed files with 8 additions and 1301 deletions

View File

@ -1 +0,0 @@
export GREENLIGHT_DB_DSN=

View File

@ -1,4 +1,3 @@
include .envrc
# ==================================================================================== # # ==================================================================================== #
# HELPERS # HELPERS
# ==================================================================================== # # ==================================================================================== #
@ -16,21 +15,8 @@ confirm:
## run/api: run the cmd/api application ## run/api: run the cmd/api application
.PHONY: run/api .PHONY: run/api
run/api: run/api:
go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} go run ./cmd/api
## db/psql: connect to the database using psql
.PHONY: db/psql
db/psql:
psql ${GREENLIGHT_DB_DSN}
## db/migrations/new name=$1: create a new database migration
.PHONY: db/migrations/new
db/migrations/new:
@echo 'Creating migration files for ${name}...'
migrate create -seq -ext=.sql -dir=./migrations ${name}
## db/migrations/up: apply all up database migrations
.PHONY: db/migrations/up
db/migrations/up: confirm
@echo 'Running up migrations...'
migrate -path ./migrations -database ${GREENLIGHT_DB_DSN} up
# ==================================================================================== # # ==================================================================================== #
# QUALITY CONTROL # QUALITY CONTROL
# ==================================================================================== # # ==================================================================================== #

View File

@ -1,8 +1,6 @@
package main package main
import ( import (
"context"
"database/sql"
"expvar" "expvar"
"flag" "flag"
"fmt" "fmt"
@ -15,7 +13,6 @@ import (
_ "github.com/lib/pq" _ "github.com/lib/pq"
"greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/jsonlog" "greenlight.alexedwards.net/internal/jsonlog"
"greenlight.alexedwards.net/internal/mailer"
"greenlight.alexedwards.net/internal/vcs" "greenlight.alexedwards.net/internal/vcs"
) )
@ -53,7 +50,6 @@ type application struct {
config config config config
logger *jsonlog.Logger logger *jsonlog.Logger
models data.Models models data.Models
mailer mailer.Mailer
wg sync.WaitGroup wg sync.WaitGroup
} }
@ -92,22 +88,12 @@ func main() {
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo) logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
defer db.Close()
logger.PrintInfo("database connection pool established", nil) logger.PrintInfo("database connection pool established", nil)
expvar.NewString("version").Set(version) expvar.NewString("version").Set(version)
expvar.Publish("goroutines", expvar.Func(func() any { expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine() return runtime.NumGoroutine()
})) }))
expvar.Publish("database", expvar.Func(func() any {
return db.Stats()
}))
expvar.Publish("timestamp", expvar.Func(func() any { expvar.Publish("timestamp", expvar.Func(func() any {
return time.Now().Unix() return time.Now().Unix()
})) }))
@ -115,33 +101,10 @@ func main() {
app := &application{ app := &application{
config: cfg, config: cfg,
logger: logger, logger: logger,
models: data.NewModels(db),
mailer: mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender),
} }
err = app.serve() err := app.serve()
if err != nil { if err != nil {
logger.PrintFatal(err, nil) logger.PrintFatal(err, nil)
} }
} }
func openDB(cfg config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.db.dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.db.maxOpenConns)
db.SetMaxIdleConns(cfg.db.maxIdleConns)
duration, err := time.ParseDuration(cfg.db.maxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = db.PingContext(ctx)
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"errors"
"expvar" "expvar"
"fmt" "fmt"
"net/http" "net/http"
@ -14,7 +13,6 @@ import (
"github.com/tomasen/realip" "github.com/tomasen/realip"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/validator"
) )
func (app *application) recoverPanic(next http.Handler) http.Handler { func (app *application) recoverPanic(next http.Handler) http.Handler {
@ -88,24 +86,8 @@ func (app *application) authenticate(next http.Handler) http.Handler {
} }
token := headerParts[1] token := headerParts[1]
v := validator.New() fmt.Println("LOG: TOKEN", "TODO: DEAL WITH TOKEN", token)
// r = app.contextSetUser(r, user)
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
app.invalidAuthenticationTokenResponse(w, r)
return
}
user, err := app.models.Users.GetForToken(data.ScopeAuthentication, token)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidAuthenticationTokenResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
r = app.contextSetUser(r, user)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@ -136,15 +118,7 @@ func (app *application) requiredActivatedUser(next http.HandlerFunc) http.Handle
func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc { func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
user := app.contextGetUser(r) user := app.contextGetUser(r)
permissions, err := app.models.Permissions.GetAllForUser(user.ID) fmt.Println("LOG: PERMISSION", "TODO: DEAL WITH PERMISSION", user)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if !permissions.Include(code) {
app.notPermittedResponse(w, r)
return
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
} }
return app.requiredActivatedUser(fn) return app.requiredActivatedUser(fn)

View File

@ -1,204 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/validator"
)
func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Year int32 `json:"year"`
Runtime data.Runtime `json:"runtime"`
Genres []string `json:"genres"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
movie := &data.Movie{
Title: input.Title,
Year: input.Year,
Runtime: input.Runtime,
Genres: input.Genres,
}
v := validator.New()
if data.ValidateMovie(v, movie); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Movies.Insert(movie)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/movies/%d", movie.ID))
err = app.writeJSON(w, http.StatusCreated, envelope{"movie": movie}, headers)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Foo string `json:"foo"`
}
body, err := io.ReadAll(r.Body)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = json.Unmarshal(body, &input)
if err != nil {
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
return
}
fmt.Fprintf(w, "%+v\n", input)
}
func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
movie, err := app.models.Movies.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
movie, err := app.models.Movies.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
var input struct {
Title *string `json:"title"`
Year *int32 `json:"year"`
Runtime *data.Runtime `json:"runtime"`
Genres []string `json:"genres"`
}
err = app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
if input.Title != nil {
movie.Title = *input.Title
}
if input.Year != nil {
movie.Year = *input.Year
}
if input.Runtime != nil {
movie.Runtime = *input.Runtime
}
if input.Genres != nil {
movie.Genres = input.Genres
}
v := validator.New()
if data.ValidateMovie(v, movie); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Movies.Update(movie)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteMovieHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
err = app.models.Movies.Delete(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"message": "movie successfully deleted"}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string
Genres []string
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Title = app.readString(qs, "title", "")
input.Genres = app.readCSV(qs, "genres", []string{})
input.Filters.Page = app.readInt(qs, "page", 1, v)
input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)
input.Filters.Sort = app.readString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
movies, metadata, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies, "metadata": metadata}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

View File

@ -13,16 +13,6 @@ func (app *application) routes() http.Handler {
router.NotFound = http.HandlerFunc(app.notFoundResponse) router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowResponse) router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowResponse)
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler))
router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler))
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler))
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler))
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler))
router.HandlerFunc(http.MethodPost, "/v1/example", app.exampleHandler)
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
router.HandlerFunc(http.MethodPost, "/v1/tokens/activation", app.createActivationTokenHandler)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
router.Handler(http.MethodGet, "/debug/vars", expvar.Handler()) router.Handler(http.MethodGet, "/debug/vars", expvar.Handler())
return app.metrics( return app.metrics(
app.recoverPanic( app.recoverPanic(

View File

@ -1,114 +0,0 @@
package main
import (
"errors"
"net/http"
"time"
"greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/validator"
)
func (app *application) createActivationTokenHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateEmail(v, input.Email); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
v.AddError("email", "no matching email address found")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
if user.Activated {
v.AddError("email", "user has already been activated")
app.failedValidationResponse(w, r, v.Errors)
return
}
token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
app.background(func() {
data := map[string]any{
"activationToken": token.Plaintext,
}
err := app.mailer.Send(user.Email, "token_activation.tmpl", data)
if err != nil {
app.logger.PrintError(err, nil)
}
})
env := envelope{"message": "an email will be sent to you containing activation instructions"}
err = app.writeJSON(w, http.StatusAccepted, env, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
v := validator.New()
data.ValidateEmail(v, input.Email)
data.ValidatePasswordPlaintext(v, input.Password)
if !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidCredentialsResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
match, err := user.Password.Matches(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if !match {
app.invalidCredentialsResponse(w, r)
return
}
token, err := app.models.Tokens.New(user.ID, 24*time.Hour, data.ScopeAuthentication)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

View File

@ -1,122 +0,0 @@
package main
import (
"errors"
"net/http"
"time"
"greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/validator"
)
func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
user := &data.User{
Name: input.Name,
Email: input.Email,
Activated: false,
}
err = user.Password.Set(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateUser(v, user); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Users.Insert(user)
if err != nil {
switch {
case errors.Is(err, data.ErrDuplicateEmail):
v.AddError("email", "a user with this email address already exists")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.models.Permissions.AddForUser(user.ID, "movies:read")
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
app.background(func() {
data := map[string]any{
"activationToken": token.Plaintext,
"userID": user.ID,
}
err := app.mailer.Send(user.Email, "user_welcome.tmpl", data)
if err != nil {
app.logger.PrintError(err, nil)
}
})
err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
TokenPlaintext string `json:"token"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
v.AddError("token", "invalid or expired activation token")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
user.Activated = true
err = app.models.Users.Update(user)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

View File

@ -11,29 +11,12 @@ var (
) )
type Models struct { 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, filters Filters) ([]*Movie, Metadata, error)
}
Users UserModel
Tokens TokenModel
Permissions PermissonModel
} }
func NewModels(db *sql.DB) Models { func NewModels(db *sql.DB) Models {
return Models{ return Models{}
Movies: MovieModel{DB: db},
Users: UserModel{DB: db},
Tokens: TokenModel{DB: db},
Permissions: PermissonModel{DB: db},
}
} }
func NewMockModels() Models { func NewMockModels() Models {
return Models{ return Models{}
Movies: MockMovieModel{},
}
} }

View File

@ -1,208 +0,0 @@
package data
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/lib/pq"
"greenlight.alexedwards.net/internal/validator"
)
type Movie struct {
ID int64 `json:"id"`
CreateAt time.Time `json:"-"`
Title string `json:"title"`
Year int32 `json:"year,omitempty"`
Runtime Runtime `json:"-"`
Genres []string `json:"genres,omitempty"`
Version int32 `json:"version"`
}
func (m Movie) MarshalJSON() ([]byte, error) {
var runtime string
if m.Runtime != 0 {
runtime = fmt.Sprintf("%d mins", m.Runtime)
}
type MovieAlis Movie
aux := struct {
MovieAlis
Runtime string `json:"runtime,omitempty"`
}{
MovieAlis: (MovieAlis)(m),
Runtime: runtime,
}
return json.Marshal(aux)
}
func ValidateMovie(v *validator.Validator, movie *Movie) {
v.Check(movie.Title != "", "title", "must be provided")
v.Check(len(movie.Title) <= 500, "title", "must not be more than 500 bytes long")
v.Check(movie.Year != 0, "year", "must be provided")
v.Check(movie.Year >= 1888, "year", "must be greater than 1888")
v.Check(movie.Year <= int32(time.Now().Year()), "year", "must not bve in the future")
v.Check(movie.Runtime != 0, "runtime", "must be provided")
v.Check(movie.Runtime > 0, "runtime", "must be a positive integer")
v.Check(movie.Genres != nil, "genres", "must be provided")
v.Check(len(movie.Genres) >= 1, "genres", "must contain at least 1 genre")
v.Check(len(movie.Genres) <= 5, "genres", "must not contain more than 5 genres")
}
type MovieModel struct {
DB *sql.DB
}
func (m MovieModel) Insert(movie *Movie) error {
query := `
INSERT INTO movies (title, year, runtime, genres)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, version
`
args := []any{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.ID, &movie.CreateAt, &movie.Version)
}
func (m MovieModel) Get(id int64) (*Movie, error) {
if id < 1 {
return nil, ErrRecordNotFound
}
query := `
SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE id = $1`
var movie Movie
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := m.DB.QueryRowContext(ctx, query, id).Scan(
&movie.ID,
&movie.CreateAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &movie, nil
}
func (m MovieModel) Update(movie *Movie) error {
query := `
UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version`
args := []any{
movie.Title,
movie.Year,
movie.Runtime,
pq.Array(movie.Genres),
movie.ID,
movie.Version,
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
func (m MovieModel) Delete(id int64) error {
if id < 1 {
return ErrRecordNotFound
}
query := `
DELETE FROM movies
WHERE id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := m.DB.ExecContext(ctx, query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, Metadata, error) {
query := fmt.Sprintf(`
SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery($1) or $1 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY %s %s, id ASC
LIMIT $3 OFFSET $4`, filters.sortColumn(), filters.sortDirection())
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
args := []any{title, pq.Array(genres), filters.limit(), filters.offset()}
rows, err := m.DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, Metadata{}, err
}
defer rows.Close()
totalRecords := 0
movies := []*Movie{}
for rows.Next() {
var movie Movie
err := rows.Scan(
&totalRecords,
&movie.ID,
&movie.CreateAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
if err != nil {
return nil, Metadata{}, err
}
movies = append(movies, &movie)
}
if err = rows.Err(); err != nil {
return nil, Metadata{}, err
}
metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)
return movies, metadata, nil
}
type MockMovieModel struct{}
func (m MockMovieModel) Insert(movie *Movie) error {
return nil
}
func (m MockMovieModel) Get(id int64) (*Movie, error) {
return nil, nil
}
func (m MockMovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, Metadata, error) {
return nil, Metadata{}, nil
}
func (m MockMovieModel) Update(movie *Movie) error {
return nil
}
func (m MockMovieModel) Delete(id int64) error {
return nil
}

View File

@ -1,64 +0,0 @@
package data
import (
"context"
"database/sql"
"time"
"github.com/lib/pq"
)
type Permissions []string
func (p Permissions) Include(code string) bool {
for i := range p {
if code == p[i] {
return true
}
}
return false
}
type PermissonModel struct {
DB *sql.DB
}
func (m PermissonModel) GetAllForUser(userID int64) (Permissions, error) {
query := `
SELECT permissions.code
FROM permissions
INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id
INNER JOIN users ON users_permissions.user_id = users.id
WHERE users.id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var permissions Permissions
for rows.Next() {
var permission string
err = rows.Scan(&permission)
if err != nil {
return nil, err
}
permissions = append(permissions, permission)
}
if err = rows.Err(); err != nil {
return nil, err
}
return permissions, nil
}
func (m PermissonModel) AddForUser(userID int64, codes ...string) error {
query := `
INSERT INTO users_permissions
SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, userID, pq.Array(codes))
return err
}

View File

@ -1,36 +0,0 @@
package data
import (
"errors"
"fmt"
"strconv"
"strings"
)
var ErrInvalidRuntimeFormat = errors.New("invalid runtime format")
type Runtime int32
func (r Runtime) MarshalJSON() ([]byte, error) {
jsonValue := fmt.Sprintf("%d mins", r)
quotedJSONValue := strconv.Quote(jsonValue)
return []byte(quotedJSONValue), nil
}
func (r *Runtime) UnmarshalJSON(jsonValue []byte) error {
unquotedJSONValue, err := strconv.Unquote(string(jsonValue))
if err != nil {
return ErrInvalidRuntimeFormat
}
parts := strings.Split(unquotedJSONValue, " ")
if len(parts) != 2 || parts[1] != "mins" {
return ErrInvalidRuntimeFormat
}
i, err := strconv.ParseInt(parts[0], 10, 32)
if err != nil {
return ErrInvalidRuntimeFormat
}
*r = Runtime(i)
return nil
}

View File

@ -1,83 +0,0 @@
package data
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base32"
"encoding/base64"
"time"
"greenlight.alexedwards.net/internal/validator"
)
const (
ScopeActivation = "activation"
ScopeAuthentication = "authentication"
)
type Token struct {
Plaintext string `json:"token"`
Hash []byte `json:"-"`
UserID int64 `json:"-"`
Expiry time.Time `json:"expiry"`
Scope string `json:"-"`
}
func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) {
token := &Token{
UserID: userID,
Expiry: time.Now().Add(ttl),
Scope: scope,
}
randomBytes := make([]byte, 16)
_, err := rand.Read(randomBytes)
if err != nil {
return nil, err
}
token.Plaintext = base64.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
hash := sha256.Sum256([]byte(token.Plaintext))
token.Hash = hash[:]
return token, nil
}
func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) {
v.Check(tokenPlaintext != "", "token", "must be provided")
// v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
}
type TokenModel struct {
DB *sql.DB
}
func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) {
token, err := generateToken(userID, ttl, scope)
if err != nil {
return nil, err
}
err = m.Insert(token)
return token, err
}
func (m TokenModel) Insert(token *Token) error {
query := `
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4)`
args := []any{token.Hash, token.UserID, token.Expiry, token.Scope}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, args...)
return err
}
func (m TokenModel) DeleteAllForUser(scope string, userID int64) error {
query := `
DELETE FROM tokens
WHERE scope = $1 AND user_id = $2`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, scope, userID)
return err
}

View File

@ -1,14 +1,8 @@
package data package data
import ( import (
"context"
"crypto/sha256"
"database/sql"
"errors" "errors"
"time" "time"
"golang.org/x/crypto/bcrypt"
"greenlight.alexedwards.net/internal/validator"
) )
var ( var (
@ -35,165 +29,3 @@ type password struct {
func (u *User) IsAnonymous() bool { func (u *User) IsAnonymous() bool {
return u == AnonymousUser return u == AnonymousUser
} }
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.CreateAt, &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.CreateAt,
&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
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) {
tokenHash := sha256.Sum256([]byte(tokenPlaintext))
query := `
SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1
AND tokens.scope = $2
AND tokens.expiry > $3`
args := []any{tokenHash[:], tokenScope, time.Now()}
var user User
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := m.DB.QueryRowContext(ctx, query, args...).Scan(
&user.ID,
&user.CreateAt,
&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
}

View File

@ -1,60 +0,0 @@
package mailer
import (
"bytes"
"embed"
"text/template"
"time"
"github.com/go-mail/mail/v2"
)
//go:embed "templates"
var templateFS embed.FS
type Mailer struct {
dialer *mail.Dialer
sender string
}
func New(host string, port int, username, password, sender string) Mailer {
dialer := mail.NewDialer(host, port, username, password)
dialer.Timeout = 5 * time.Second
return Mailer{
dialer: dialer,
sender: sender,
}
}
func (m *Mailer) Send(recipient, templateFile string, data any) error {
tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile)
if err != nil {
return err
}
subject := new(bytes.Buffer)
err = tmpl.ExecuteTemplate(subject, "subject", data)
if err != nil {
return err
}
plainBody := new(bytes.Buffer)
err = tmpl.ExecuteTemplate(plainBody, "plainBody", data)
if err != nil {
return err
}
htmlBody := new(bytes.Buffer)
err = tmpl.ExecuteTemplate(htmlBody, "htmlBody", data)
if err != nil {
return err
}
msg := mail.NewMessage()
msg.SetHeader("To", recipient)
msg.SetHeader("From", m.sender)
msg.SetHeader("Subject", subject.String())
msg.SetBody("text/plain", plainBody.String())
msg.AddAlternative("text/html", htmlBody.String())
err = m.dialer.DialAndSend(msg)
if err != nil {
return err
}
return nil
}

View File

@ -1,28 +0,0 @@
{{define "subject"}}Activate your Greenlight account{{end}}
{{define "plainBody"}}
Hi,
Please send a `PUT /v1/users/activated` request with the following JSON body to activate your account:
{"token": "{{.activationToken}}"}
Please note that this is a one-time use token and it will expire in 3 days.
Thanks,
The Greenlight Team
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Please send a <code>PUT /v1/users/activated</code> request with the following JSON body to activate your account:</p>
<pre><code>
{"token": "{{.activationToken}}"}
</code></pre>
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
<p>Thanks,</p>
<p>The Greenlight Team</p>
</body>
</html>
{{end}}

View File

@ -1,45 +0,0 @@
{{define "subject"}}Welcom to Greenlight!{{end}}
{{define "plainBody"}}
Hi,
Thanks for signing for a Greenlight account. We're excited to have you on board!
For furute reference, your user ID number is {{.userID}}.
Please send a request to the `PUT /v1/users/activated` endpoint with the follow JSON body to activate your account:
{"token": "{{.activationToken}}"}
Please note that this is a one-time use token an it will expire in 3 days.
Thanks,
The Greenlight Team
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
<p>For future reference, your user ID number is {{.userID}}.</p>
<p>Please send a request to the <code>PUT /v1/users/activated</code> endpoint with the
following JSON body to activate your account:</p>
<pre><code>
{"token": "{{.activationToken}}"}
</code></pre>
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
<p>Thanks,</p>
<p>The Greenlight Team</p>
</body>
</html>
{{end}}

View File

@ -1,2 +0,0 @@
DROP EXTENSION IF EXISTS citext;
DROP TABLE IF EXISTS movies;

View File

@ -1,10 +0,0 @@
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE IF NOT EXISTS movies (
id bigserial PRIMARY KEY,
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
title text NOT NULL,
year integer NOT NULL,
runtime integer NOT NULL,
genres text[] NOT NULL,
version integer NOT NULL DEFAULT 1
);

View File

@ -1,3 +0,0 @@
ALTER TABLE movies DROP CONSTRAINT IF EXISTS movies_runtime_check;
ALTER TABLE movies DROP CONSTRAINT IF EXISTS movies_year_check;
ALTER TABLE movies DROP CONSTRAINT IF EXISTS genres_length_check;

View File

@ -1,3 +0,0 @@
ALTER TABLE movies ADD CONSTRAINT movies_run_time CHECK (runtime >= 0);
ALTER TABLE movies ADD CONSTRAINT movies_year_check CHECK (year BETWEEN 1888 AND DATE_PART('year', NOW()));
ALTER TABLE movies ADD CONSTRAINT genres_length_check CHECK ( ARRAY_LENGTH(genres, 1) BETWEEN 1 AND 5);

View File

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS movies_title_idx;
DROP INDEX IF EXISTS movies_genres_idx;

View File

@ -1,2 +0,0 @@
CREATE INDEX IF NOT EXISTS movies_title_idx ON movies USING GIN (to_tsvector('simple', title));
CREATE INDEX IF NOT EXISTS movies_genres_idx ON movies USING GIN (genres);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS users;

View File

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id bigserial PRIMARY KEY,
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
name text NOT NULL,
email citext UNIQUE NOT NULL,
password_hash bytea NOT NULL,
activated bool NOT NULL,
version integer NOT NULL DEFAULT 1
);

View File

@ -1 +0,0 @@
DROP TABLE if EXISTS tokens;

View File

@ -1,6 +0,0 @@
CREATE TABLE IF NOT EXISTS tokens (
hash bytea PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
expiry timestamp(0) with time zone NOT NULL,
scope text NOT NULL
);

View File

@ -1,2 +0,0 @@
DROP TABLE IF EXISTS users_permissions;
DROP TABLE IF EXISTS permissions;

View File

@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS permissions (
id bigserial PRIMARY KEY,
code text NOT NULL
);
CREATE TABLE IF NOT EXISTS users_permissions (
user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE,
PRIMARY KEY (user_id, permission_id)
);
INSERT INTO permissions (code)
VALUES
('movies:read'),
('movies:write');