diff --git a/.dev.envrc b/.dev.envrc deleted file mode 100644 index 1b7fcbd..0000000 --- a/.dev.envrc +++ /dev/null @@ -1 +0,0 @@ -export GREENLIGHT_DB_DSN= \ No newline at end of file diff --git a/Makefile b/Makefile index 78bc520..4317f93 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ -include .envrc # ==================================================================================== # # HELPERS # ==================================================================================== # @@ -16,21 +15,8 @@ confirm: ## run/api: run the cmd/api application .PHONY: run/api run/api: - go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} -## 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 + go run ./cmd/api + # ==================================================================================== # # QUALITY CONTROL # ==================================================================================== # diff --git a/cmd/api/main.go b/cmd/api/main.go index cf9f782..ed2359f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,8 +1,6 @@ package main import ( - "context" - "database/sql" "expvar" "flag" "fmt" @@ -15,7 +13,6 @@ import ( _ "github.com/lib/pq" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/jsonlog" - "greenlight.alexedwards.net/internal/mailer" "greenlight.alexedwards.net/internal/vcs" ) @@ -53,7 +50,6 @@ type application struct { config config logger *jsonlog.Logger models data.Models - mailer mailer.Mailer wg sync.WaitGroup } @@ -92,22 +88,12 @@ func main() { 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) expvar.NewString("version").Set(version) expvar.Publish("goroutines", expvar.Func(func() any { return runtime.NumGoroutine() })) - expvar.Publish("database", expvar.Func(func() any { - return db.Stats() - })) expvar.Publish("timestamp", expvar.Func(func() any { return time.Now().Unix() })) @@ -115,33 +101,10 @@ func main() { app := &application{ config: cfg, 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 { 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 -} diff --git a/cmd/api/middleware.go b/cmd/api/middleware.go index 038bf55..96bc672 100644 --- a/cmd/api/middleware.go +++ b/cmd/api/middleware.go @@ -1,7 +1,6 @@ package main import ( - "errors" "expvar" "fmt" "net/http" @@ -14,7 +13,6 @@ import ( "github.com/tomasen/realip" "golang.org/x/time/rate" "greenlight.alexedwards.net/internal/data" - "greenlight.alexedwards.net/internal/validator" ) 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] - v := validator.New() - - 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) + fmt.Println("LOG: TOKEN", "TODO: DEAL WITH TOKEN", token) + // r = app.contextSetUser(r, user) 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 { fn := func(w http.ResponseWriter, r *http.Request) { user := app.contextGetUser(r) - permissions, err := app.models.Permissions.GetAllForUser(user.ID) - if err != nil { - app.serverErrorResponse(w, r, err) - return - } - if !permissions.Include(code) { - app.notPermittedResponse(w, r) - return - } + fmt.Println("LOG: PERMISSION", "TODO: DEAL WITH PERMISSION", user) next.ServeHTTP(w, r) } return app.requiredActivatedUser(fn) diff --git a/cmd/api/movies.go b/cmd/api/movies.go deleted file mode 100644 index f6a424a..0000000 --- a/cmd/api/movies.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/api/routes.go b/cmd/api/routes.go index c66baa6..60fc46f 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -13,16 +13,6 @@ func (app *application) routes() http.Handler { router.NotFound = http.HandlerFunc(app.notFoundResponse) router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowResponse) 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()) return app.metrics( app.recoverPanic( diff --git a/cmd/api/tokens.go b/cmd/api/tokens.go deleted file mode 100644 index f6badbb..0000000 --- a/cmd/api/tokens.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/api/users.go b/cmd/api/users.go deleted file mode 100644 index 0b8d789..0000000 --- a/cmd/api/users.go +++ /dev/null @@ -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) - } - -} diff --git a/internal/data/models.go b/internal/data/models.go index 60cac4a..c49e75f 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -11,29 +11,12 @@ 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, filters Filters) ([]*Movie, Metadata, error) - } - Users UserModel - Tokens TokenModel - Permissions PermissonModel } func NewModels(db *sql.DB) Models { - return Models{ - Movies: MovieModel{DB: db}, - Users: UserModel{DB: db}, - Tokens: TokenModel{DB: db}, - Permissions: PermissonModel{DB: db}, - } + return Models{} } func NewMockModels() Models { - return Models{ - Movies: MockMovieModel{}, - } + return Models{} } diff --git a/internal/data/movies.go b/internal/data/movies.go deleted file mode 100644 index b38d3ab..0000000 --- a/internal/data/movies.go +++ /dev/null @@ -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 -} diff --git a/internal/data/permissions.go b/internal/data/permissions.go deleted file mode 100644 index 4517c75..0000000 --- a/internal/data/permissions.go +++ /dev/null @@ -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 -} diff --git a/internal/data/runtime.go b/internal/data/runtime.go deleted file mode 100644 index 16c8833..0000000 --- a/internal/data/runtime.go +++ /dev/null @@ -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 -} diff --git a/internal/data/tokens.go b/internal/data/tokens.go deleted file mode 100644 index 5c30a54..0000000 --- a/internal/data/tokens.go +++ /dev/null @@ -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 -} diff --git a/internal/data/users.go b/internal/data/users.go index 9526ce4..9fe9d63 100644 --- a/internal/data/users.go +++ b/internal/data/users.go @@ -1,14 +1,8 @@ package data import ( - "context" - "crypto/sha256" - "database/sql" "errors" "time" - - "golang.org/x/crypto/bcrypt" - "greenlight.alexedwards.net/internal/validator" ) var ( @@ -35,165 +29,3 @@ type password struct { func (u *User) IsAnonymous() bool { 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 -} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go deleted file mode 100644 index 787d095..0000000 --- a/internal/mailer/mailer.go +++ /dev/null @@ -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 -} diff --git a/internal/mailer/templates/token_activation.tmpl b/internal/mailer/templates/token_activation.tmpl deleted file mode 100644 index 19a81c2..0000000 --- a/internal/mailer/templates/token_activation.tmpl +++ /dev/null @@ -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"}} - - - - - - - -

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}} \ No newline at end of file diff --git a/internal/mailer/templates/user_welcome.tmpl b/internal/mailer/templates/user_welcome.tmpl deleted file mode 100644 index bf690e9..0000000 --- a/internal/mailer/templates/user_welcome.tmpl +++ /dev/null @@ -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"}} - - - - - - - - -

Hi,

-

Thanks for signing up for a Greenlight account. We're excited to have you on board!

-

For future reference, your user ID number is {{.userID}}.

-

Please send a request to the PUT /v1/users/activated endpoint 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}} \ No newline at end of file diff --git a/migrations/000001_create_movies_table.down.sql b/migrations/000001_create_movies_table.down.sql deleted file mode 100644 index 71f823f..0000000 --- a/migrations/000001_create_movies_table.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP EXTENSION IF EXISTS citext; -DROP TABLE IF EXISTS movies; \ No newline at end of file diff --git a/migrations/000001_create_movies_table.up.sql b/migrations/000001_create_movies_table.up.sql deleted file mode 100644 index 400ac31..0000000 --- a/migrations/000001_create_movies_table.up.sql +++ /dev/null @@ -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 -); \ No newline at end of file diff --git a/migrations/000002_add_movies_check_constraints.down.sql b/migrations/000002_add_movies_check_constraints.down.sql deleted file mode 100644 index 1f9a7c3..0000000 --- a/migrations/000002_add_movies_check_constraints.down.sql +++ /dev/null @@ -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; \ No newline at end of file diff --git a/migrations/000002_add_movies_check_constraints.up.sql b/migrations/000002_add_movies_check_constraints.up.sql deleted file mode 100644 index 69c6cad..0000000 --- a/migrations/000002_add_movies_check_constraints.up.sql +++ /dev/null @@ -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); \ No newline at end of file diff --git a/migrations/000003_add_movies_indexes.down.sql b/migrations/000003_add_movies_indexes.down.sql deleted file mode 100644 index 0fd0992..0000000 --- a/migrations/000003_add_movies_indexes.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS movies_title_idx; -DROP INDEX IF EXISTS movies_genres_idx; \ No newline at end of file diff --git a/migrations/000003_add_movies_indexes.up.sql b/migrations/000003_add_movies_indexes.up.sql deleted file mode 100644 index 66a3fcd..0000000 --- a/migrations/000003_add_movies_indexes.up.sql +++ /dev/null @@ -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); \ No newline at end of file diff --git a/migrations/000004_create_users_table.down.sql b/migrations/000004_create_users_table.down.sql deleted file mode 100644 index 365a210..0000000 --- a/migrations/000004_create_users_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/000004_create_users_table.up.sql b/migrations/000004_create_users_table.up.sql deleted file mode 100644 index 0270e45..0000000 --- a/migrations/000004_create_users_table.up.sql +++ /dev/null @@ -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 -); \ No newline at end of file diff --git a/migrations/000005_create_tokens_table.down.sql b/migrations/000005_create_tokens_table.down.sql deleted file mode 100644 index 2ad8425..0000000 --- a/migrations/000005_create_tokens_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE if EXISTS tokens; \ No newline at end of file diff --git a/migrations/000005_create_tokens_table.up.sql b/migrations/000005_create_tokens_table.up.sql deleted file mode 100644 index fe5af56..0000000 --- a/migrations/000005_create_tokens_table.up.sql +++ /dev/null @@ -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 -); \ No newline at end of file diff --git a/migrations/000006_add_permissions.down.sql b/migrations/000006_add_permissions.down.sql deleted file mode 100644 index e11949f..0000000 --- a/migrations/000006_add_permissions.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS users_permissions; -DROP TABLE IF EXISTS permissions; \ No newline at end of file diff --git a/migrations/000006_add_permissions.up.sql b/migrations/000006_add_permissions.up.sql deleted file mode 100644 index 0899f3a..0000000 --- a/migrations/000006_add_permissions.up.sql +++ /dev/null @@ -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'); \ No newline at end of file