From b01f9918611525925f954537f1627e133f435f97 Mon Sep 17 00:00:00 2001 From: example <> Date: Thu, 2 Jan 2025 11:54:15 +0800 Subject: [PATCH] Initial commit --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 3 + Makefile | 53 +++++++++ README.md | 27 +++++ cmd/api/context.go | 25 ++++ cmd/api/errors.go | 82 ++++++++++++++ cmd/api/healthcheck.go | 20 ++++ cmd/api/helpers.go | 121 ++++++++++++++++++++ cmd/api/main.go | 110 ++++++++++++++++++ cmd/api/middleware.go | 170 ++++++++++++++++++++++++++++ cmd/api/routes.go | 27 +++++ cmd/api/server.go | 60 ++++++++++ cmd/examples/cors/preflight/main.go | 53 +++++++++ cmd/examples/cors/simple/main.go | 44 +++++++ go.mod | 11 ++ go.sum | 10 ++ internal/data/filters.go | 67 +++++++++++ internal/data/models.go | 22 ++++ internal/data/users.go | 31 +++++ internal/jsonlog/jsonlog.go | 92 +++++++++++++++ internal/validator/validator.go | 53 +++++++++ internal/vcs/vcs.go | 31 +++++ 22 files changed, 1112 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/api/context.go create mode 100644 cmd/api/errors.go create mode 100644 cmd/api/healthcheck.go create mode 100644 cmd/api/helpers.go create mode 100644 cmd/api/main.go create mode 100644 cmd/api/middleware.go create mode 100644 cmd/api/routes.go create mode 100644 cmd/api/server.go create mode 100644 cmd/examples/cors/preflight/main.go create mode 100644 cmd/examples/cors/simple/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/data/filters.go create mode 100644 internal/data/models.go create mode 100644 internal/data/users.go create mode 100644 internal/jsonlog/jsonlog.go create mode 100644 internal/validator/validator.go create mode 100644 internal/vcs/vcs.go diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a3585876b6842aef6ec2eca2c673c194487ea3b6 GIT binary patch literal 6148 zcmeH~O=`nH427SXECStl+2w30pQvfVyQljO&;ssLc!1UOG})p;=82 zR;?Ceh}WZ?+UmMqI#RP8R>OzYoz15hnq@nzF`-!xQ4j$USP", "SMTP sender email address") + + flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error { + cfg.cors.trustedOrigins = strings.Fields(val) + return nil + }) + + displayVersion := flag.Bool("version", false, "Display version and exit") + + flag.Parse() + + if *displayVersion { + fmt.Printf("Version: \t%s\n", version) + os.Exit(0) + } + + logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo) + + 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) +}