initial commit
This commit is contained in:
211
internal/server/middleware/middleware.go
Normal file
211
internal/server/middleware/middleware.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"base/config"
|
||||
"base/pkg/jwt"
|
||||
"base/pkg/metrics"
|
||||
)
|
||||
|
||||
type Middleware interface {
|
||||
Metrics() gin.HandlerFunc
|
||||
FileSizeLimit(maxSize int64) gin.HandlerFunc
|
||||
AuthShield() gin.HandlerFunc
|
||||
}
|
||||
|
||||
type middleware struct {
|
||||
metrics *metrics.Metrics
|
||||
logger zerolog.Logger
|
||||
config *config.AppConfig
|
||||
tokenService jwt.TokenService
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Metrics *metrics.Metrics
|
||||
Logger zerolog.Logger
|
||||
Config *config.AppConfig
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
const (
|
||||
UserIDKey = "userID"
|
||||
)
|
||||
|
||||
func NewMiddleware(lc fx.Lifecycle, param Param) Middleware {
|
||||
lc.Append(fx.Hook{})
|
||||
|
||||
return &middleware{
|
||||
metrics: param.Metrics,
|
||||
logger: param.Logger,
|
||||
config: param.Config,
|
||||
tokenService: jwt.New(param.Config.JWT.Secret, param.Config.JWT.AccessTokenExpiration, param.Config.JWT.RefreshTokenExpiration),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *middleware) AuthShield() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var accessToken string
|
||||
|
||||
// Fallback to Authorization header
|
||||
authorizationHeader := c.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
m.logger.Warn().
|
||||
Str("path", c.Request.URL.Path).
|
||||
Msg("Authorization header is empty")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"message": "unauthorized",
|
||||
"status": http.StatusUnauthorized,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authorizationHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
m.logger.Warn().
|
||||
Str("header", authorizationHeader).
|
||||
Str("path", c.Request.URL.Path).
|
||||
Msg("Authorization header format is invalid")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"message": "unauthorized",
|
||||
"status": http.StatusUnauthorized,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
accessToken = parts[1]
|
||||
if accessToken == "" {
|
||||
m.logger.Warn().
|
||||
Str("path", c.Request.URL.Path).
|
||||
Msg("Authorization token is empty")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"message": "unauthorized",
|
||||
"status": http.StatusUnauthorized,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
m.logger.Debug().
|
||||
Str("path", c.Request.URL.Path).
|
||||
Msg("Using access token from Authorization header")
|
||||
|
||||
// Verify token
|
||||
token, err := m.tokenService.VerifyToken(c.Request.Context(), accessToken)
|
||||
if err != nil {
|
||||
m.logger.Warn().
|
||||
Err(err).
|
||||
Str("path", c.Request.URL.Path).
|
||||
Msg("Authorization token is invalid")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"message": "unauthorized",
|
||||
"status": http.StatusUnauthorized,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Debug().
|
||||
Str("sub", token.Sub).
|
||||
Str("path", c.Request.URL.Path).
|
||||
Msg("Authorization token is valid")
|
||||
|
||||
c.Set(UserIDKey, token.Sub)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *middleware) Metrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
recorder := &StatusRecorder{
|
||||
ResponseWriter: c.Writer,
|
||||
statusCode: http.StatusOK, // Default status code
|
||||
}
|
||||
|
||||
// Replace the original ResponseWriter with the StatusRecorder
|
||||
c.Writer = recorder
|
||||
|
||||
c.Next()
|
||||
|
||||
statusCode := recorder.GetStatusCode()
|
||||
|
||||
path := c.Request.URL.Path
|
||||
if path == "/health" || path == "/metrics" || path == "/health/live" || strings.Contains(path, "/swagger/") {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize path to prevent metric cardinality explosion
|
||||
normalizedPath := m.metrics.NormalizePath(path)
|
||||
m.metrics.RecordHTTPRequest(c.Request.Method, normalizedPath, strconv.Itoa(statusCode), time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *middleware) FileSizeLimit(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check if this is a multipart form request
|
||||
if c.Request.MultipartForm == nil {
|
||||
// Parse multipart form to get file size
|
||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
if err.Error() == "http: request body too large" {
|
||||
m.logger.Warn().
|
||||
Int64("maxSize", maxSize).
|
||||
Str("path", c.Request.URL.Path).
|
||||
Str("ip", c.ClientIP()).
|
||||
Msg("File size limit exceeded")
|
||||
|
||||
c.JSON(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
gin.H{
|
||||
"error": fmt.Sprintf("File size exceeds the maximum allowed size of %d bytes", maxSize),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Other parsing errors should not block the request
|
||||
m.logger.Error().Err(err).Msg("Failed to parse multipart form")
|
||||
}
|
||||
}
|
||||
|
||||
// Check individual file sizes
|
||||
if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil {
|
||||
for fieldName, files := range c.Request.MultipartForm.File {
|
||||
for _, file := range files {
|
||||
if file.Size > maxSize {
|
||||
m.logger.Warn().
|
||||
Int64("fileSize", file.Size).
|
||||
Int64("maxSize", maxSize).
|
||||
Str("filename", file.Filename).
|
||||
Str("fieldName", fieldName).
|
||||
Str("path", c.Request.URL.Path).
|
||||
Str("ip", c.ClientIP()).
|
||||
Msg("File size limit exceeded")
|
||||
|
||||
c.JSON(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
gin.H{
|
||||
"error": fmt.Sprintf("File '%s' size (%d bytes) exceeds the maximum allowed size of %d bytes",
|
||||
file.Filename, file.Size, maxSize),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
5
internal/server/middleware/model.go
Normal file
5
internal/server/middleware/model.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package middleware
|
||||
|
||||
type User struct {
|
||||
Permissions []string
|
||||
}
|
||||
50
internal/server/middleware/utils.go
Normal file
50
internal/server/middleware/utils.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type StatusRecorder struct {
|
||||
gin.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
// WriteHeader records the status code and calls the original WriteHeader.
|
||||
func (sr *StatusRecorder) WriteHeader(code int) {
|
||||
sr.statusCode = code
|
||||
sr.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// GetStatusCode returns the recorded status code.
|
||||
func (sr *StatusRecorder) GetStatusCode() int {
|
||||
return sr.statusCode
|
||||
}
|
||||
|
||||
func authorize(user *User, routePermission string, httpRoutePermissionMap map[string]string) bool {
|
||||
for routePattern, requiredPermission := range httpRoutePermissionMap {
|
||||
if matchRoute(routePermission, routePattern) {
|
||||
return isPermitted(user, requiredPermission)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchRoute(route string, pattern string) bool {
|
||||
regexPattern := strings.ReplaceAll(pattern, "{param}", "[^/]+")
|
||||
regexPattern = "^" + regexPattern + "$"
|
||||
re := regexp.MustCompile(regexPattern)
|
||||
return re.MatchString(route)
|
||||
}
|
||||
|
||||
func isPermitted(user *User, permission string) bool {
|
||||
for _, p := range user.Permissions {
|
||||
if p == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
178
internal/server/server.go
Normal file
178
internal/server/server.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/rs/zerolog"
|
||||
swaggerfiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"base/config"
|
||||
"base/internal/delivery/http/platform"
|
||||
"base/internal/dto"
|
||||
"base/internal/server/middleware"
|
||||
"base/pkg/health"
|
||||
)
|
||||
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Engine *gin.Engine
|
||||
Config *config.AppConfig
|
||||
Logger zerolog.Logger
|
||||
Public *platform.Controller
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// StartHTTPServer starts the HTTP server
|
||||
func StartHTTPServer(lifecycle fx.Lifecycle, params Params) {
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", params.Config.Server.WebHost, params.Config.Server.WebPort),
|
||||
Handler: params.Engine,
|
||||
}
|
||||
|
||||
lifecycle.Append(
|
||||
fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
params.Logger.Info().Str("module", "http").Msg("Starting HTTP server")
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
params.Logger.Error().Err(err).Msg("HTTP server failed to start")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
params.Logger.Info().Str("module", "http").Msg("Stopping HTTP server")
|
||||
return server.Shutdown(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NewGinEngine creates a new Gin HTTP engine
|
||||
func NewGinEngine(logger zerolog.Logger) *gin.Engine {
|
||||
// Set Gin mode
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
|
||||
// Use custom middlewares
|
||||
r.Use(gin.CustomRecovery(CustomRecoveryWithLogger(logger)))
|
||||
|
||||
// Use logger middleware
|
||||
r.Use(
|
||||
func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
c.Next()
|
||||
|
||||
end := time.Now()
|
||||
latency := end.Sub(start)
|
||||
|
||||
if path == "/health" || path == "/health/live" || path == "/metrics" {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("method", c.Request.Method).
|
||||
Str("path", path).
|
||||
Str("ip", c.ClientIP()).
|
||||
Int("status", c.Writer.Status()).
|
||||
Dur("latency", latency).
|
||||
Msg("request completed")
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func registerRoutes(engine *gin.Engine, mid middleware.Middleware) {
|
||||
// Prometheus metrics endpoint
|
||||
engine.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
|
||||
engine.Use(mid.Metrics())
|
||||
|
||||
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
|
||||
}
|
||||
|
||||
func healthCheckers(db *gorm.DB) []health.Checker {
|
||||
return []health.Checker{
|
||||
health.DatabaseHealthChecker(db),
|
||||
}
|
||||
}
|
||||
|
||||
func registerHealthRoute(engine *gin.Engine, params Params) {
|
||||
engine.GET("/health", func(c *gin.Context) {
|
||||
checkers := healthCheckers(params.DB)
|
||||
response := health.Health(c.Request.Context(), "1.0.0", checkers...)
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if response.Status == health.StatusUnhealthy {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
} else if response.Status == health.StatusDegraded {
|
||||
statusCode = http.StatusOK // Degraded is still considered OK for HTTP
|
||||
}
|
||||
|
||||
c.JSON(statusCode, response)
|
||||
})
|
||||
|
||||
// Simple health check endpoint for load balancers
|
||||
engine.GET("/health/ready", func(c *gin.Context) {
|
||||
checkers := healthCheckers(params.DB)
|
||||
response := health.Health(c.Request.Context(), "1.0.0", checkers...)
|
||||
|
||||
// For readiness, we only care if the service is healthy or degraded
|
||||
// Unhealthy means the service is not ready to serve traffic
|
||||
if response.Status == health.StatusUnhealthy {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "not ready",
|
||||
"timestamp": time.Now(),
|
||||
"message": "Service is not ready to serve traffic",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ready",
|
||||
"timestamp": time.Now(),
|
||||
"message": "Service is ready to serve traffic",
|
||||
})
|
||||
})
|
||||
|
||||
// Liveness check endpoint
|
||||
engine.GET("/health/live", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "alive",
|
||||
"timestamp": time.Now(),
|
||||
"message": "Service is alive",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var Server = fx.Module(
|
||||
"server",
|
||||
fx.Provide(NewGinEngine),
|
||||
fx.Invoke(StartHTTPServer, registerRoutes, registerHealthRoute),
|
||||
)
|
||||
|
||||
func CustomRecoveryWithLogger(logger zerolog.Logger) gin.RecoveryFunc {
|
||||
return func(c *gin.Context, err interface{}) {
|
||||
logger.Error().
|
||||
Interface("error", err).
|
||||
Str("path", c.Request.URL.Path).
|
||||
Str("method", c.Request.Method).
|
||||
Msg("panic recovered")
|
||||
|
||||
c.JSON(http.StatusInternalServerError, dto.InternalServerError())
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user