commit b01f9918611525925f954537f1627e133f435f97 Author: example <> Date: Thu Jan 2 11:54:15 2025 +0800 Initial commit 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/.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..4317f93 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +# ==================================================================================== # +# 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 + +# ==================================================================================== # +# 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..ed2359f --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "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/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 + 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) + + logger.PrintInfo("database connection pool established", nil) + + expvar.NewString("version").Set(version) + expvar.Publish("goroutines", expvar.Func(func() any { + return runtime.NumGoroutine() + })) + expvar.Publish("timestamp", expvar.Func(func() any { + return time.Now().Unix() + })) + + app := &application{ + config: cfg, + logger: logger, + } + + err := app.serve() + if err != nil { + logger.PrintFatal(err, nil) + } +} diff --git a/cmd/api/middleware.go b/cmd/api/middleware.go new file mode 100644 index 0000000..96bc672 --- /dev/null +++ b/cmd/api/middleware.go @@ -0,0 +1,170 @@ +package main + +import ( + "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" +) + +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] + fmt.Println("LOG: TOKEN", "TODO: DEAL WITH TOKEN", token) + // 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) + fmt.Println("LOG: PERMISSION", "TODO: DEAL WITH PERMISSION", user) + 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().Add("Vary", "Origin") + w.Header().Add("Vary", "Access-Control-Request-Method") + origin := r.Header.Get("Origin") + if origin != "" && len(app.config.cors.trustedOrigins) != 0 { + 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 + } + } + } else { + w.Header().Set("Access-Control-Allow-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 + } + } + 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/routes.go b/cmd/api/routes.go new file mode 100644 index 0000000..60fc46f --- /dev/null +++ b/cmd/api/routes.go @@ -0,0 +1,27 @@ +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.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/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..990763a --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module greenlight.alexedwards.net + +go 1.22.3 + +require ( + github.com/felixge/httpsnoop v1.0.4 + 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/time v0.5.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a15a82 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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..c49e75f --- /dev/null +++ b/internal/data/models.go @@ -0,0 +1,22 @@ +package data + +import ( + "database/sql" + "errors" +) + +var ( + ErrRecordNotFound = errors.New("record not found") + ErrEditConflict = errors.New("edit conflict") +) + +type Models struct { +} + +func NewModels(db *sql.DB) Models { + return Models{} +} + +func NewMockModels() Models { + return Models{} +} diff --git a/internal/data/users.go b/internal/data/users.go new file mode 100644 index 0000000..9fe9d63 --- /dev/null +++ b/internal/data/users.go @@ -0,0 +1,31 @@ +package data + +import ( + "errors" + "time" +) + +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 +} 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/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) +}