feature: 初始化项目
This commit is contained in:
parent
b1027ef885
commit
2ca213e37e
|
|
@ -1 +0,0 @@
|
||||||
export GREENLIGHT_DB_DSN=
|
|
||||||
18
Makefile
18
Makefile
|
|
@ -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
|
||||||
# ==================================================================================== #
|
# ==================================================================================== #
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
cmd/api/users.go
122
cmd/api/users.go
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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{},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -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}}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
DROP EXTENSION IF EXISTS citext;
|
|
||||||
DROP TABLE IF EXISTS movies;
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
DROP INDEX IF EXISTS movies_title_idx;
|
|
||||||
DROP INDEX IF EXISTS movies_genres_idx;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
DROP TABLE if EXISTS tokens;
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
DROP TABLE IF EXISTS users_permissions;
|
|
||||||
DROP TABLE IF EXISTS permissions;
|
|
||||||
|
|
@ -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');
|
|
||||||
Loading…
Reference in New Issue
Block a user