feature: golang server template
This commit is contained in:
commit
2f1a7dd334
1
.dev.envrc
Normal file
1
.dev.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export GREENLIGHT_DB_DSN=
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
bin/
|
||||||
|
vendor/
|
||||||
|
.envrc
|
||||||
67
Makefile
Normal file
67
Makefile
Normal file
|
|
@ -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
|
||||||
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -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!
|
||||||
|
```
|
||||||
25
cmd/api/context.go
Normal file
25
cmd/api/context.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
82
cmd/api/errors.go
Normal file
82
cmd/api/errors.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
20
cmd/api/healthcheck.go
Normal file
20
cmd/api/healthcheck.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
cmd/api/helpers.go
Normal file
121
cmd/api/helpers.go
Normal file
|
|
@ -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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
147
cmd/api/main.go
Normal file
147
cmd/api/main.go
Normal file
|
|
@ -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 <no-reply@greenlight.alexedwards.not>", "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
|
||||||
|
}
|
||||||
189
cmd/api/middleware.go
Normal file
189
cmd/api/middleware.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
204
cmd/api/movies.go
Normal file
204
cmd/api/movies.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
cmd/api/routes.go
Normal file
37
cmd/api/routes.go
Normal file
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
60
cmd/api/server.go
Normal file
60
cmd/api/server.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
114
cmd/api/tokens.go
Normal file
114
cmd/api/tokens.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
cmd/api/users.go
Normal file
122
cmd/api/users.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
53
cmd/examples/cors/preflight/main.go
Normal file
53
cmd/examples/cors/preflight/main.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Simple CORS</h1>
|
||||||
|
<div id="output"></div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
fetch("http://localhost:4000/v1/tokens/authentication", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "alice@example.com",
|
||||||
|
password: "pa55word",
|
||||||
|
})
|
||||||
|
}).then(
|
||||||
|
function(response) {
|
||||||
|
response.text().then(function(text) {
|
||||||
|
document.getElementById('output').innerHTML = text
|
||||||
|
})
|
||||||
|
},
|
||||||
|
function(err) {
|
||||||
|
document.getElementById('output').innerHTML = err
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
44
cmd/examples/cors/simple/main.go
Normal file
44
cmd/examples/cors/simple/main.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Simple CORS</h1>
|
||||||
|
<div id="output"></div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
fetch("http://localhost:4000/v1/healthcheck").then(
|
||||||
|
function(response) {
|
||||||
|
response.text().then(function(text) {
|
||||||
|
document.getElementById('output').innerHTML = text
|
||||||
|
})
|
||||||
|
},
|
||||||
|
function(err) {
|
||||||
|
document.getElementById('output').innerHTML = err
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
18
go.mod
Normal file
18
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
18
go.sum
Normal file
18
go.sum
Normal file
|
|
@ -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=
|
||||||
67
internal/data/filters.go
Normal file
67
internal/data/filters.go
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
39
internal/data/models.go
Normal file
39
internal/data/models.go
Normal file
|
|
@ -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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
208
internal/data/movies.go
Normal file
208
internal/data/movies.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
64
internal/data/permissions.go
Normal file
64
internal/data/permissions.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
36
internal/data/runtime.go
Normal file
36
internal/data/runtime.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
83
internal/data/tokens.go
Normal file
83
internal/data/tokens.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
199
internal/data/users.go
Normal file
199
internal/data/users.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
92
internal/jsonlog/jsonlog.go
Normal file
92
internal/jsonlog/jsonlog.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
60
internal/mailer/mailer.go
Normal file
60
internal/mailer/mailer.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
28
internal/mailer/templates/token_activation.tmpl
Normal file
28
internal/mailer/templates/token_activation.tmpl
Normal file
|
|
@ -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"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Hi,</p>
|
||||||
|
<p>Please send a <code>PUT /v1/users/activated</code> request with the following JSON body to activate your account:</p>
|
||||||
|
<pre><code>
|
||||||
|
{"token": "{{.activationToken}}"}
|
||||||
|
</code></pre>
|
||||||
|
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
|
||||||
|
<p>Thanks,</p>
|
||||||
|
<p>The Greenlight Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
45
internal/mailer/templates/user_welcome.tmpl
Normal file
45
internal/mailer/templates/user_welcome.tmpl
Normal file
|
|
@ -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"}}
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Hi,</p>
|
||||||
|
<p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
|
||||||
|
<p>For future reference, your user ID number is {{.userID}}.</p>
|
||||||
|
<p>Please send a request to the <code>PUT /v1/users/activated</code> endpoint with the
|
||||||
|
following JSON body to activate your account:</p>
|
||||||
|
<pre><code>
|
||||||
|
{"token": "{{.activationToken}}"}
|
||||||
|
</code></pre>
|
||||||
|
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
|
||||||
|
<p>Thanks,</p>
|
||||||
|
<p>The Greenlight Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
53
internal/validator/validator.go
Normal file
53
internal/validator/validator.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
31
internal/vcs/vcs.go
Normal file
31
internal/vcs/vcs.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
1
migrations/000001_create_movies_table.down.sql
Normal file
1
migrations/000001_create_movies_table.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS movies;
|
||||||
9
migrations/000001_create_movies_table.up.sql
Normal file
9
migrations/000001_create_movies_table.up.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
3
migrations/000002_add_movies_check_constraints.down.sql
Normal file
3
migrations/000002_add_movies_check_constraints.down.sql
Normal file
|
|
@ -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;
|
||||||
3
migrations/000002_add_movies_check_constraints.up.sql
Normal file
3
migrations/000002_add_movies_check_constraints.up.sql
Normal file
|
|
@ -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);
|
||||||
2
migrations/000003_add_movies_indexes.down.sql
Normal file
2
migrations/000003_add_movies_indexes.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP INDEX IF EXISTS movies_title_idx;
|
||||||
|
DROP INDEX IF EXISTS movies_genres_idx;
|
||||||
2
migrations/000003_add_movies_indexes.up.sql
Normal file
2
migrations/000003_add_movies_indexes.up.sql
Normal file
|
|
@ -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);
|
||||||
1
migrations/000004_create_users_table.down.sql
Normal file
1
migrations/000004_create_users_table.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
9
migrations/000004_create_users_table.up.sql
Normal file
9
migrations/000004_create_users_table.up.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
1
migrations/000005_create_tokens_table.down.sql
Normal file
1
migrations/000005_create_tokens_table.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE if EXISTS tokens;
|
||||||
6
migrations/000005_create_tokens_table.up.sql
Normal file
6
migrations/000005_create_tokens_table.up.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
2
migrations/000006_add_permissions.down.sql
Normal file
2
migrations/000006_add_permissions.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS users_permissions;
|
||||||
|
DROP TABLE IF EXISTS permissions;
|
||||||
15
migrations/000006_add_permissions.up.sql
Normal file
15
migrations/000006_add_permissions.up.sql
Normal file
|
|
@ -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');
|
||||||
Loading…
Reference in New Issue
Block a user