From 2f1a7dd334b8164b7772fa0638facda9e222262e Mon Sep 17 00:00:00 2001 From: leonmin <1334137558@qq.com> Date: Mon, 8 Jul 2024 17:36:56 +0800 Subject: [PATCH] feature: golang server template --- .DS_Store | Bin 0 -> 6148 bytes .dev.envrc | 1 + .gitignore | 3 + Makefile | 67 ++++++ README.md | 27 +++ cmd/api/context.go | 25 +++ cmd/api/errors.go | 82 +++++++ cmd/api/healthcheck.go | 20 ++ cmd/api/helpers.go | 121 ++++++++++ cmd/api/main.go | 147 +++++++++++++ cmd/api/middleware.go | 189 ++++++++++++++++ cmd/api/movies.go | 204 +++++++++++++++++ cmd/api/routes.go | 37 ++++ cmd/api/server.go | 60 +++++ cmd/api/tokens.go | 114 ++++++++++ cmd/api/users.go | 122 ++++++++++ cmd/examples/cors/preflight/main.go | 53 +++++ cmd/examples/cors/simple/main.go | 44 ++++ go.mod | 18 ++ go.sum | 18 ++ internal/data/filters.go | 67 ++++++ internal/data/models.go | 39 ++++ internal/data/movies.go | 208 ++++++++++++++++++ internal/data/permissions.go | 64 ++++++ internal/data/runtime.go | 36 +++ internal/data/tokens.go | 83 +++++++ internal/data/users.go | 199 +++++++++++++++++ internal/jsonlog/jsonlog.go | 92 ++++++++ internal/mailer/mailer.go | 60 +++++ .../mailer/templates/token_activation.tmpl | 28 +++ internal/mailer/templates/user_welcome.tmpl | 45 ++++ internal/validator/validator.go | 53 +++++ internal/vcs/vcs.go | 31 +++ .../000001_create_movies_table.down.sql | 1 + migrations/000001_create_movies_table.up.sql | 9 + ...0002_add_movies_check_constraints.down.sql | 3 + ...000002_add_movies_check_constraints.up.sql | 3 + migrations/000003_add_movies_indexes.down.sql | 2 + migrations/000003_add_movies_indexes.up.sql | 2 + migrations/000004_create_users_table.down.sql | 1 + migrations/000004_create_users_table.up.sql | 9 + .../000005_create_tokens_table.down.sql | 1 + migrations/000005_create_tokens_table.up.sql | 6 + migrations/000006_add_permissions.down.sql | 2 + migrations/000006_add_permissions.up.sql | 15 ++ 45 files changed, 2411 insertions(+) create mode 100644 .DS_Store create mode 100644 .dev.envrc create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/api/context.go create mode 100644 cmd/api/errors.go create mode 100644 cmd/api/healthcheck.go create mode 100644 cmd/api/helpers.go create mode 100644 cmd/api/main.go create mode 100644 cmd/api/middleware.go create mode 100644 cmd/api/movies.go create mode 100644 cmd/api/routes.go create mode 100644 cmd/api/server.go create mode 100644 cmd/api/tokens.go create mode 100644 cmd/api/users.go create mode 100644 cmd/examples/cors/preflight/main.go create mode 100644 cmd/examples/cors/simple/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/data/filters.go create mode 100644 internal/data/models.go create mode 100644 internal/data/movies.go create mode 100644 internal/data/permissions.go create mode 100644 internal/data/runtime.go create mode 100644 internal/data/tokens.go create mode 100644 internal/data/users.go create mode 100644 internal/jsonlog/jsonlog.go create mode 100644 internal/mailer/mailer.go create mode 100644 internal/mailer/templates/token_activation.tmpl create mode 100644 internal/mailer/templates/user_welcome.tmpl create mode 100644 internal/validator/validator.go create mode 100644 internal/vcs/vcs.go create mode 100644 migrations/000001_create_movies_table.down.sql create mode 100644 migrations/000001_create_movies_table.up.sql create mode 100644 migrations/000002_add_movies_check_constraints.down.sql create mode 100644 migrations/000002_add_movies_check_constraints.up.sql create mode 100644 migrations/000003_add_movies_indexes.down.sql create mode 100644 migrations/000003_add_movies_indexes.up.sql create mode 100644 migrations/000004_create_users_table.down.sql create mode 100644 migrations/000004_create_users_table.up.sql create mode 100644 migrations/000005_create_tokens_table.down.sql create mode 100644 migrations/000005_create_tokens_table.up.sql create mode 100644 migrations/000006_add_permissions.down.sql create mode 100644 migrations/000006_add_permissions.up.sql diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a3585876b6842aef6ec2eca2c673c194487ea3b6 GIT binary patch literal 6148 zcmeH~O=`nH427SXECStl+2w30pQvfVyQljO&;ssLc!1UOG})p;=82 zR;?Ceh}WZ?+UmMqI#RP8R>OzYoz15hnq@nzF`-!xQ4j$USP", "SMTP sender email address") + + flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error { + cfg.cors.trustedOrigins = strings.Fields(val) + return nil + }) + + displayVersion := flag.Bool("version", false, "Display version and exit") + + flag.Parse() + + if *displayVersion { + fmt.Printf("Version: \t%s\n", version) + os.Exit(0) + } + + 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() + })) + + 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() + 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 new file mode 100644 index 0000000..d180f68 --- /dev/null +++ b/cmd/api/middleware.go @@ -0,0 +1,189 @@ +package main + +import ( + "errors" + "expvar" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/felixge/httpsnoop" + "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 { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.Header().Set("Connection", "close") + app.serverErrorResponse(w, r, fmt.Errorf("%s", err)) + } + }() + next.ServeHTTP(w, r) + }) +} + +func (app *application) rateLimit(next http.Handler) http.Handler { + type clinet struct { + limiter *rate.Limiter + lastSeen time.Time + } + var ( + mu sync.Mutex + clients = make(map[string]*clinet) + ) + go func() { + for { + time.Sleep(time.Minute) + mu.Lock() + for ip, client := range clients { + if time.Since(client.lastSeen) > 3*time.Minute { + delete(clients, ip) + } + } + mu.Unlock() + } + }() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if app.config.limiter.enabled { + ip := realip.FromRequest(r) + mu.Lock() + if _, found := clients[ip]; !found { + clients[ip] = &clinet{ + limiter: rate.NewLimiter(rate.Limit(app.config.limiter.rps), app.config.limiter.burst), + } + } + clients[ip].lastSeen = time.Now() + if !clients[ip].limiter.Allow() { + mu.Unlock() + app.rateLimitExceededResponse(w, r) + return + } + mu.Unlock() + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Vary", "Authorization") + authorizationHeader := r.Header.Get("Authorization") + if authorizationHeader == "" { + r = app.contextSetUser(r, data.AnonymousUser) + next.ServeHTTP(w, r) + return + } + + headerParts := strings.Split(authorizationHeader, " ") + if len(headerParts) != 2 || headerParts[0] != "Bearer" { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + 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) + next.ServeHTTP(w, r) + }) +} + +func (app *application) requiredAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + if user.IsAnonymous() { + app.authenticationRequiredResponse(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) requiredActivatedUser(next http.HandlerFunc) http.HandlerFunc { + fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + if !user.Activated { + app.inactiveAccountResponse(w, r) + return + } + next.ServeHTTP(w, r) + }) + return app.requiredAuthenticatedUser(fn) +} + +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 + } + next.ServeHTTP(w, r) + } + return app.requiredActivatedUser(fn) +} + +func (app *application) enableCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Add("Vary", "Origin") + w.Header().Add("Vary", "Access-Control-Request-Method") + origin := r.Header.Get("Origin") + if origin != "" { + for i := range app.config.cors.trustedOrigins { + if origin == app.config.cors.trustedOrigins[i] { + w.Header().Set("Access-Control-Allow-Origin", origin) + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.WriteHeader(http.StatusOK) + return + } + break + } + } + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) metrics(next http.Handler) http.Handler { + totalRequestsReceived := expvar.NewInt("total_requests_received") + totalResponseSent := expvar.NewInt("total_response_sent") + totalProcessingTimeMicroseconds := expvar.NewInt("total_processing_time_microseconds") + totalResponsesSentByStatus := expvar.NewMap("total_responses_sent_by_status") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + totalRequestsReceived.Add(1) + metrics := httpsnoop.CaptureMetrics(next, w, r) + totalResponseSent.Add(1) + totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds()) + totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code), 1) + }) +} diff --git a/cmd/api/movies.go b/cmd/api/movies.go new file mode 100644 index 0000000..f6a424a --- /dev/null +++ b/cmd/api/movies.go @@ -0,0 +1,204 @@ +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 new file mode 100644 index 0000000..c66baa6 --- /dev/null +++ b/cmd/api/routes.go @@ -0,0 +1,37 @@ +package main + +import ( + "expvar" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +func (app *application) routes() http.Handler { + router := httprouter.New() + + 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( + app.enableCORS( + app.rateLimit( + app.authenticate(router), + ), + ), + ), + ) + +} diff --git a/cmd/api/server.go b/cmd/api/server.go new file mode 100644 index 0000000..98dcc40 --- /dev/null +++ b/cmd/api/server.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func (app *application) serve() error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", app.config.port), + Handler: app.routes(), + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + shutdownError := make(chan error) + + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + s := <-quit + app.logger.PrintInfo("shutting down server", map[string]string{ + "signal": s.String(), + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + err := srv.Shutdown(ctx) + if err != nil { + shutdownError <- err + } + app.logger.PrintInfo("completing background tasks", map[string]string{ + "addr": srv.Addr, + }) + app.wg.Wait() + shutdownError <- nil + }() + app.logger.PrintInfo("starting server", map[string]string{ + "addr": srv.Addr, + "env": app.config.env, + }) + err := srv.ListenAndServe() + if !errors.Is(err, http.ErrServerClosed) { + return err + } + err = <-shutdownError + if err != nil { + return err + } + app.logger.PrintInfo("stopped server", map[string]string{ + "addr": srv.Addr, + }) + return nil +} diff --git a/cmd/api/tokens.go b/cmd/api/tokens.go new file mode 100644 index 0000000..f6badbb --- /dev/null +++ b/cmd/api/tokens.go @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..0b8d789 --- /dev/null +++ b/cmd/api/users.go @@ -0,0 +1,122 @@ +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/cmd/examples/cors/preflight/main.go b/cmd/examples/cors/preflight/main.go new file mode 100644 index 0000000..3ccbd56 --- /dev/null +++ b/cmd/examples/cors/preflight/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "log" + "net/http" +) + +const html = ` + + + + + + +

Simple CORS

+
+ + + +` + +func main() { + addr := flag.String("addr", ":9000", "Server address") + flag.Parse() + log.Printf("Starting server on %s", *addr) + err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(html)) + })) + log.Fatal(err) +} diff --git a/cmd/examples/cors/simple/main.go b/cmd/examples/cors/simple/main.go new file mode 100644 index 0000000..58d9082 --- /dev/null +++ b/cmd/examples/cors/simple/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "flag" + "log" + "net/http" +) + +const html = ` + + + + + + +

Simple CORS

+
+ + + +` + +func main() { + addr := flag.String("addr", ":9000", "Server address") + flag.Parse() + log.Printf("Starting server on %s", *addr) + err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(html)) + })) + log.Fatal(err) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57c0846 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module greenlight.alexedwards.net + +go 1.22.3 + +require ( + github.com/felixge/httpsnoop v1.0.4 + github.com/go-mail/mail/v2 v2.3.0 + github.com/julienschmidt/httprouter v1.3.0 + github.com/lib/pq v1.10.2 + github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce + golang.org/x/crypto v0.24.0 + golang.org/x/time v0.5.0 +) + +require ( + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/mail.v2 v2.3.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1529974 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw= +github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= diff --git a/internal/data/filters.go b/internal/data/filters.go new file mode 100644 index 0000000..6c14b94 --- /dev/null +++ b/internal/data/filters.go @@ -0,0 +1,67 @@ +package data + +import ( + "math" + "strings" + + "greenlight.alexedwards.net/internal/validator" +) + +type Filters struct { + Page int + PageSize int + Sort string + SortSafelist []string +} +type Metadata struct { + CurrentPage int `json:"current_page,omitempty"` + PageSize int `json:"page_size,omitempty"` + FirstPage int `json:"first_page,omitempty"` + LastPage int `json:"last_page,omitempty"` + TotalRecords int `json:"total_records,omitempty"` +} + +func ValidateFilters(v *validator.Validator, f Filters) { + v.Check(f.Page > 0, "page", "must be greater than zero") + v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10,000,000") + v.Check(f.PageSize > 0, "page_size", "must be greater than zero") + v.Check(f.Page <= 100, "page_size", "must be a maximum of 100") + v.Check(validator.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value") +} + +func (f Filters) limit() int { + return f.PageSize +} + +func (f Filters) offset() int { + return (f.Page - 1) * f.PageSize +} + +func calculateMetadata(totalRecords, page, pageSize int) Metadata { + if totalRecords == 0 { + return Metadata{} + } + return Metadata{ + CurrentPage: page, + PageSize: pageSize, + FirstPage: 1, + LastPage: int(math.Ceil(float64(totalRecords) / float64(pageSize))), + TotalRecords: totalRecords, + } +} + +func (f Filters) sortColumn() string { + for _, safeValue := range f.SortSafelist { + if f.Sort == safeValue { + return strings.TrimPrefix(f.Sort, "-") + } + } + panic("unsafe sort parameter: " + f.Sort) +} + +func (f Filters) sortDirection() string { + if strings.HasPrefix(f.Sort, "-") { + return "DESC" + } + return "ASC" +} diff --git a/internal/data/models.go b/internal/data/models.go new file mode 100644 index 0000000..60cac4a --- /dev/null +++ b/internal/data/models.go @@ -0,0 +1,39 @@ +package data + +import ( + "database/sql" + "errors" +) + +var ( + ErrRecordNotFound = errors.New("record not found") + ErrEditConflict = errors.New("edit conflict") +) + +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}, + } +} + +func NewMockModels() Models { + return Models{ + Movies: MockMovieModel{}, + } +} diff --git a/internal/data/movies.go b/internal/data/movies.go new file mode 100644 index 0000000..b38d3ab --- /dev/null +++ b/internal/data/movies.go @@ -0,0 +1,208 @@ +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 new file mode 100644 index 0000000..4517c75 --- /dev/null +++ b/internal/data/permissions.go @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..16c8833 --- /dev/null +++ b/internal/data/runtime.go @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..5c30a54 --- /dev/null +++ b/internal/data/tokens.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..9526ce4 --- /dev/null +++ b/internal/data/users.go @@ -0,0 +1,199 @@ +package data + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "greenlight.alexedwards.net/internal/validator" +) + +var ( + ErrDuplicateEmail = errors.New("duplicate email") +) + +type User struct { + ID int64 `json:"id"` + CreateAt time.Time `json:"create_at"` + Name string `json:"name"` + Email string `json:"email"` + Password password `json:"-"` + Activated bool `json:"activated"` + Version int `json:"-"` +} + +var AnonymousUser = &User{} + +type password struct { + plaintext *string + hash []byte +} + +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/jsonlog/jsonlog.go b/internal/jsonlog/jsonlog.go new file mode 100644 index 0000000..5e3875a --- /dev/null +++ b/internal/jsonlog/jsonlog.go @@ -0,0 +1,92 @@ +package jsonlog + +import ( + "encoding/json" + "io" + "os" + "runtime/debug" + "sync" + "time" +) + +type Level int8 + +const ( + LevelInfo Level = iota + LevelError + LevelFatal + LevelOff +) + +func (l Level) String() string { + switch l { + case LevelInfo: + return "INFO" + case LevelError: + return "ERROR" + case LevelFatal: + return "FATAL" + default: + return "" + } +} + +type Logger struct { + out io.Writer + minLevel Level + mu sync.Mutex +} + +func New(out io.Writer, minLevel Level) *Logger { + return &Logger{ + out: out, + minLevel: minLevel, + } +} + +func (l *Logger) PrintInfo(message string, properties map[string]string) { + l.print(LevelInfo, message, properties) +} + +func (l *Logger) PrintError(err error, properties map[string]string) { + l.print(LevelError, err.Error(), properties) +} + +func (l *Logger) PrintFatal(err error, properties map[string]string) { + l.print(LevelFatal, err.Error(), properties) + os.Exit(1) +} + +func (l *Logger) print(level Level, message string, properties map[string]string) (int, error) { + if level < l.minLevel { + return 0, nil + } + aux := struct { + Level string `json:"level"` + TIme string `json:"time"` + Message string `json:"message"` + Properties map[string]string `json:"properties,omitempty"` + Trace string `json:"trace,omitempty"` + }{ + Level: level.String(), + TIme: time.Now().UTC().Format(time.RFC3339), + Message: message, + Properties: properties, + } + + if level >= LevelError { + aux.Trace = string(debug.Stack()) + } + var line []byte + line, err := json.Marshal(aux) + if err != nil { + line = []byte(LevelError.String() + ": unable to marshal log message:" + err.Error()) + } + l.mu.Lock() + defer l.mu.Unlock() + return l.out.Write(append(line, '\n')) +} + +func (l *Logger) Write(message []byte) (n int, err error) { + return l.print(LevelError, string(message), nil) +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..787d095 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..19a81c2 --- /dev/null +++ b/internal/mailer/templates/token_activation.tmpl @@ -0,0 +1,28 @@ +{{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 new file mode 100644 index 0000000..bf690e9 --- /dev/null +++ b/internal/mailer/templates/user_welcome.tmpl @@ -0,0 +1,45 @@ +{{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/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..b372930 --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,53 @@ +package validator + +import "regexp" + +var ( + EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") +) + +type Validator struct { + Errors map[string]string +} + +func New() *Validator { + return &Validator{Errors: make(map[string]string)} +} + +func (v *Validator) Valid() bool { + return len(v.Errors) == 0 +} + +func (v *Validator) AddError(key, message string) { + if _, exists := v.Errors[key]; !exists { + v.Errors[key] = message + } +} + +func (v *Validator) Check(ok bool, key, message string) { + if !ok { + v.AddError(key, message) + } +} + +func PermittedValue[T comparable](value T, permittedValues ...T) bool { + for i := range permittedValues { + if value == permittedValues[i] { + return true + } + } + return false +} + +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} + +func Unique[T comparable](values []T) bool { + uniqueValues := make(map[T]bool) + for _, value := range values { + uniqueValues[value] = true + } + + return len(values) == len(uniqueValues) +} diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 0000000..3453086 --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,31 @@ +package vcs + +import ( + "fmt" + "runtime/debug" +) + +func Version() string { + var revision string + var modified bool + var time string + bi, ok := debug.ReadBuildInfo() + if ok { + for _, s := range bi.Settings { + switch s.Key { + case "vcs.time": + time = s.Value + case "vcs.revision": + revision = s.Value + case "vcs.modified": + if s.Value == "true" { + modified = true + } + } + } + } + if modified { + return fmt.Sprintf("%s-%s-dirty", time, revision) + } + return fmt.Sprintf("%s-%s", time, revision) +} diff --git a/migrations/000001_create_movies_table.down.sql b/migrations/000001_create_movies_table.down.sql new file mode 100644 index 0000000..3983e77 --- /dev/null +++ b/migrations/000001_create_movies_table.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..37b0484 --- /dev/null +++ b/migrations/000001_create_movies_table.up.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..1f9a7c3 --- /dev/null +++ b/migrations/000002_add_movies_check_constraints.down.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..cc151dc --- /dev/null +++ b/migrations/000002_add_movies_check_constraints.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE movies ADD CONSTRAINT movies_runtime_check 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 new file mode 100644 index 0000000..0fd0992 --- /dev/null +++ b/migrations/000003_add_movies_indexes.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..66a3fcd --- /dev/null +++ b/migrations/000003_add_movies_indexes.up.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/migrations/000004_create_users_table.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..0270e45 --- /dev/null +++ b/migrations/000004_create_users_table.up.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..2ad8425 --- /dev/null +++ b/migrations/000005_create_tokens_table.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..fe5af56 --- /dev/null +++ b/migrations/000005_create_tokens_table.up.sql @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..e11949f --- /dev/null +++ b/migrations/000006_add_permissions.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..0899f3a --- /dev/null +++ b/migrations/000006_add_permissions.up.sql @@ -0,0 +1,15 @@ +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