// Package middleware 暴露 Gin 中间件,用于 HTTP 服务器: // 请求日志、CORS、JWT 认证、管理员守卫和权限检查。 package middleware import ( "errors" "net/http" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "go.uber.org/zap" ) // Context keys for values produced by the auth middleware. const ( CtxUserID = "ctx_user_id" CtxUserRole = "ctx_user_role" CtxUserTier = "ctx_user_tier" ) // RequestLogger logs one structured line per request. // // 健康检查与静态资源的成功请求被跳过:healthcheck 每 30s 一次、SPA 静态 // 文件每页几十个请求,全部记 INFO 会让日志在几小时内膨胀到几十 MB, // 在 Docker json-file 日志驱动下白白消耗磁盘 IO。 func RequestLogger(log *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() path := c.Request.URL.Path status := c.Writer.Status() if status < 400 { if path == "/api/health" || strings.HasPrefix(path, "/assets/") || path == "/favicon.ico" || path == "/favicon.svg" { return } } log.Info("http", zap.String("method", c.Request.Method), zap.String("path", path), zap.Int("status", status), zap.Duration("dur", time.Since(start)), zap.String("ip", c.ClientIP()), ) } } // CORS implements a cross-origin policy. When debug is true and origins is // empty, all origins are allowed (dev convenience). In production // (debug=false) with an empty origins list, CORS headers are omitted // entirely so the browser enforces same-origin by default. func CORS(origins []string, debug bool) gin.HandlerFunc { allowAll := len(origins) == 0 && debug allowed := make(map[string]struct{}, len(origins)) for _, o := range origins { origin := strings.TrimSpace(o) if origin == "*" { allowAll = true continue } if origin != "" { allowed[origin] = struct{}{} } } return func(c *gin.Context) { origin := c.GetHeader("Origin") if allowAll { c.Header("Access-Control-Allow-Origin", "*") } else if _, ok := allowed[origin]; ok && origin != "" { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") c.Header("Vary", "Origin") } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, X-Emby-Token, X-MediaBrowser-Token, X-Emby-Authorization") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } // RateLimiter is a simple per-IP sliding-window rate limiter that runs // entirely in-process. It is intended for auth endpoints (login, register) // where brute-force protection is critical. type RateLimiter struct { mu sync.Mutex window time.Duration max int requests map[string][]time.Time } // NewRateLimiter creates a rate limiter allowing max requests per window // per client IP. func NewRateLimiter(max int, window time.Duration) *RateLimiter { rl := &RateLimiter{ window: window, max: max, requests: make(map[string][]time.Time), } go rl.cleanup() return rl } func (rl *RateLimiter) cleanup() { for { time.Sleep(5 * time.Minute) rl.mu.Lock() now := time.Now() for ip, times := range rl.requests { var valid []time.Time for _, t := range times { if now.Sub(t) <= rl.window { valid = append(valid, t) } } if len(valid) == 0 { delete(rl.requests, ip) } else { rl.requests[ip] = valid } } rl.mu.Unlock() } } // Allow returns true if the request from ip is within the rate limit. func (rl *RateLimiter) Allow(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() times := rl.requests[ip] var valid []time.Time for _, t := range times { if now.Sub(t) <= rl.window { valid = append(valid, t) } } if len(valid) >= rl.max { rl.requests[ip] = valid return false } rl.requests[ip] = append(valid, now) return true } // RateLimit returns a Gin middleware that rejects requests exceeding the // per-IP rate limit with 429 Too Many Requests. func RateLimit(limiter *RateLimiter) gin.HandlerFunc { return func(c *gin.Context) { if !limiter.Allow(c.ClientIP()) { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ "code": 42901, "message": "too many requests, please try again later", }) return } c.Next() } } // Claims is the JWT payload we issue. type Claims struct { UserID string `json:"uid"` Role string `json:"role"` Tier string `json:"tier,omitempty"` jwt.RegisteredClaims } // AuthRequired parses and validates a JWT from the Authorization header // (Bearer ...) or the `token` query parameter (used by