commit 2f1a7dd334b8164b7772fa0638facda9e222262e Author: leonmin <1334137558@qq.com> Date: Mon Jul 8 17:36:56 2024 +0800 feature: golang server template diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a358587 Binary files /dev/null and b/.DS_Store differ diff --git a/.dev.envrc b/.dev.envrc new file mode 100644 index 0000000..1b7fcbd --- /dev/null +++ b/.dev.envrc @@ -0,0 +1 @@ +export GREENLIGHT_DB_DSN= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfae67c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +vendor/ +.envrc \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..78bc520 --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +include .envrc +# ==================================================================================== # +# HELPERS +# ==================================================================================== # +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' +.PHONY: confirm +confirm: + @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] +# ==================================================================================== # +# DEVELOPMENT +# ==================================================================================== # +## run/api: run the cmd/api application +.PHONY: run/api +run/api: + go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} +## db/psql: connect to the database using psql +.PHONY: db/psql +db/psql: + psql ${GREENLIGHT_DB_DSN} +## db/migrations/new name=$1: create a new database migration +.PHONY: db/migrations/new +db/migrations/new: + @echo 'Creating migration files for ${name}...' + migrate create -seq -ext=.sql -dir=./migrations ${name} +## db/migrations/up: apply all up database migrations +.PHONY: db/migrations/up +db/migrations/up: confirm + @echo 'Running up migrations...' + migrate -path ./migrations -database ${GREENLIGHT_DB_DSN} up +# ==================================================================================== # +# QUALITY CONTROL +# ==================================================================================== # +## audit: tidy dependencies and format, vet and test all code +.PHONY: audit +audit: vendor + @echo 'Tidying and verifying module dependencies...' + go mod tidy + go mod verify + @echo 'Formatting code...' + go fmt ./... + @echo 'Vetting code...' + go vet ./... + @echo 'Running tests...' + go test -race -vet=off ./... + +## vendor: tidy and vendor dependencies +.PHONY: vendor +vendor: + @echo 'Tidying and verifying module dependencies...' + go mod tidy + go mod verify + @echo 'Vendoring dependencies...' + go mod vendor + +# ==================================================================================== # +# BUILD +# ==================================================================================== # + +## build/api: build the cmd/api application +.PHONY: build/api +build/api: + go build -ldflags='-s' -o ./bin/api ./cmd/api + GOOS=linux GOARCH=amd64 go build -ldflags='-s' -o ./bin/linux_amd64/api ./cmd/api \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e99e43 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +## 项目结构 + +```yaml +- bin # 可编译的二进制文件, 用于部署到服务器 +- cmd + - api # 业务代码, 处理请求,权限 + - main.go +- internel # 辅助代码, 处理数据库, 数据校验, 发邮件等 +- migrations # sql迁移文件 +- remote # 配置文件和启动脚本 +- go.mod # 项目依赖, 版本号, 模块路径 +- Makefile # 自动化处理任务, 审核代码, 生成二进制文件, 执行sql迁移 +``` + +## hello world + +```go +package main +impot "fmt" +func main() { + fmt.Println("hello world!") +} +``` +执行代码 +```bash +$ go run ./cmd/api # hello world! +``` \ No newline at end of file diff --git a/cmd/api/context.go b/cmd/api/context.go new file mode 100644 index 0000000..af9c39c --- /dev/null +++ b/cmd/api/context.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "net/http" + + "greenlight.alexedwards.net/internal/data" +) + +type contextKey string + +const userContextKey = contextKey("user") + +func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request { + ctx := context.WithValue(r.Context(), userContextKey, user) + return r.WithContext(ctx) +} + +func (app *application) contextGetUser(r *http.Request) *data.User { + user, ok := r.Context().Value(userContextKey).(*data.User) + if !ok { + panic("missing user value in request context") + } + return user +} diff --git a/cmd/api/errors.go b/cmd/api/errors.go new file mode 100644 index 0000000..3815f49 --- /dev/null +++ b/cmd/api/errors.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "net/http" +) + +func (app *application) logError(r *http.Request, err error) { + app.logger.PrintError(err, map[string]string{ + "request_method": r.Method, + "request_url": r.URL.String(), + }) +} + +func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) { + env := envelope{"error": message} + err := app.writeJSON(w, status, env, nil) + if err != nil { + app.logError(r, err) + w.WriteHeader(500) + } +} + +func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + app.logError(r, err) + message := "the server encountered a problem and could not process your request" + app.errorResponse(w, r, http.StatusInternalServerError, message) +} + +func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) { + message := "the requested resource could not be found" + app.errorResponse(w, r, http.StatusNotFound, message) +} + +func (app *application) methodNotAllowResponse(w http.ResponseWriter, r *http.Request) { + message := fmt.Sprintf("the %s method is not supported for this resource", r.Method) + app.errorResponse(w, r, http.StatusMethodNotAllowed, message) +} + +func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + app.errorResponse(w, r, http.StatusBadRequest, err.Error()) +} + +func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) +} + +func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { + message := "unable to update the record duto to an edit conflict, please try again" + app.errorResponse(w, r, http.StatusConflict, message) +} + +func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { + message := "rate limit exceeded" + app.errorResponse(w, r, http.StatusTooManyRequests, message) +} + +func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { + message := "invalid authentication credentials" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "Bearer") + message := "invalid or missing authentication token" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { + message := "you must be authenticated to access this resource" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) { + message := "you do not have the necessary permissions to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) { + message := "your account does not have the necessary permissions to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} diff --git a/cmd/api/healthcheck.go b/cmd/api/healthcheck.go new file mode 100644 index 0000000..8178631 --- /dev/null +++ b/cmd/api/healthcheck.go @@ -0,0 +1,20 @@ +package main + +import ( + "net/http" +) + +func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) { + + env := envelope{ + "status": "available", + "system_info": map[string]string{ + "environment": app.config.env, + "version": version, + }, + } + err := app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/cmd/api/helpers.go b/cmd/api/helpers.go new file mode 100644 index 0000000..45e85a4 --- /dev/null +++ b/cmd/api/helpers.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/julienschmidt/httprouter" + "greenlight.alexedwards.net/internal/validator" +) + +type envelope map[string]any + +func (app *application) readIDParam(r *http.Request) (int64, error) { + params := httprouter.ParamsFromContext(r.Context()) + id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + if err != nil || id < 1 { + return 0, errors.New("invalid id paramerer") + } + return id, nil +} + +func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { + js, err := json.MarshalIndent(data, "", "\t") + if err != nil { + return err + } + js = append(js, '\n') + for key, value := range headers { + w.Header()[key] = value + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(js) + return nil +} + +func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error { + maxBytes := 1_048_576 + r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + err := dec.Decode(dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var invalidUnmarshalError *json.InvalidUnmarshalError + var maxBytesError *http.MaxBytesError + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("body contains badly-formed JSON") + case errors.As(err, &unmarshalTypeError): + if unmarshalTypeError.Field != "" { + return fmt.Errorf("body contains an invalid value for the %q field (at character %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) + } + return fmt.Errorf("body contains an invalid value (at character %d)", unmarshalTypeError.Offset) + case errors.Is(err, io.EOF): + return errors.New("body must not be empty") + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("body contains unknown key %s", fieldName) + case errors.As(err, &maxBytesError): + return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit) + case errors.As(err, &invalidUnmarshalError): + panic(err) + default: + return err + } + } + err = dec.Decode(&struct{}{}) + if err != io.EOF { + return errors.New("body must only contain a single JSON value") + } + return nil +} +func (app *application) readString(qs url.Values, key string, defaultValue string) string { + s := qs.Get(key) + if s == "" { + return defaultValue + } + return s +} +func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string { + csv := qs.Get(key) + if csv == "" { + return defaultValue + } + return strings.Split(csv, ",") +} +func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { + s := qs.Get(key) + if s == "" { + return defaultValue + } + i, err := strconv.Atoi(s) + if err != nil { + v.AddError(key, "must be a integer value") + return defaultValue + } + return i +} + +func (app *application) background(fn func()) { + app.wg.Add(1) + go func() { + defer app.wg.Done() + defer func() { + if err := recover(); err != nil { + app.logger.PrintError(fmt.Errorf("%s", err), nil) + } + }() + fn() + }() +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..cf9f782 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "database/sql" + "expvar" + "flag" + "fmt" + "os" + "runtime" + "strings" + "sync" + "time" + + _ "github.com/lib/pq" + "greenlight.alexedwards.net/internal/data" + "greenlight.alexedwards.net/internal/jsonlog" + "greenlight.alexedwards.net/internal/mailer" + "greenlight.alexedwards.net/internal/vcs" +) + +var ( + version = vcs.Version() +) + +type config struct { + port int + env string + db struct { + dsn string + maxOpenConns int + maxIdleConns int + maxIdleTime string + } + limiter struct { + rps float64 + burst int + enabled bool + } + smtp struct { + host string + port int + username string + password string + sender string + } + cors struct { + trustedOrigins []string + } +} + +type application struct { + config config + logger *jsonlog.Logger + models data.Models + mailer mailer.Mailer + wg sync.WaitGroup +} + +func main() { + var cfg config + flag.IntVar(&cfg.port, "port", 4000, "API server port") + flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") + flag.StringVar(&cfg.db.dsn, "db-dsn", "", "PostgresSQL DSN") + flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") + flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") + flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max idle time") + + flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second") + flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst") + flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter") + + flag.StringVar(&cfg.smtp.host, "smtp-host", "smtp.mailtrap.io", "SMTP server host") + flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP server port") + flag.StringVar(&cfg.smtp.username, "smtp-username", "ebe83d2e524f7d", "SMTP server username") + flag.StringVar(&cfg.smtp.password, "smtp-password", "2a46c462463a5f", "SMTP server password") + flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight ", "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