Files
MeBox/internal/middleware/middleware.go
T
Kiro d5cf5fb4b2 feat: bootstrap MediaStationGo (Go + React rewrite of MediaStation)
Adopt the cropflre/nowen-video tech stack and rebuild the project from
scratch:

  - Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + JWT + WebSocket hub.
    Layered packages config / database / model / repository / service /
    middleware / handler. Default admin (admin/admin123) seeded on first
    run; /api routes for auth, libraries, media, stream and admin
    panels. WebSocket scan-progress events at /api/ws.
  - Frontend: React 18 + Vite 5 + Tailwind 3.4 + Zustand + axios +
    react-router 6 + lucide-react + framer-motion + hls.js. Pages for
    Login / Home / Library / Search / MediaDetail / Player / Admin
    (Library, Users, Settings tabs).
  - Distribution: multi-arch Dockerfile (frontend -> backend -> Alpine
    runtime), docker-compose.yml, GitHub Actions for CI and GHCR
    publish, Makefile, env-prefixed config (MEDIASTATION_*).
  - Docs: README, CONTRIBUTING, .env.example, config.example.yaml.

Backend builds, vets and tests pass. Frontend builds via tsc -b && vite
build (250 kB JS / 16 kB CSS, gzipped 84 / 4 kB).
2026-05-14 15:26:29 +00:00

118 lines
3.3 KiB
Go

// Package middleware exposes Gin middlewares used by the HTTP server:
// request logging, CORS, JWT authentication and admin guard.
package middleware
import (
"errors"
"net/http"
"strings"
"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"
)
// RequestLogger logs one structured line per request.
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
log.Info("http",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("dur", time.Since(start)),
zap.String("ip", c.ClientIP()),
)
}
}
// CORS implements a permissive cross-origin policy when origins is empty
// (development convenience) and a strict allow-list otherwise.
func CORS(origins []string) gin.HandlerFunc {
allowAll := len(origins) == 0
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
allowed[strings.TrimSpace(o)] = 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")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// Claims is the JWT payload we issue.
type Claims struct {
UserID string `json:"uid"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// AuthRequired parses and validates a JWT from the Authorization header
// (Bearer ...) or the `token` query parameter (used by <video>.src).
func AuthRequired(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
raw := extractToken(c)
if raw == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims := &Claims{}
_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || claims.UserID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
c.Next()
}
}
// AdminRequired must run AFTER AuthRequired; it enforces role == "admin".
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get(CtxUserRole)
if role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
c.Next()
}
}
func extractToken(c *gin.Context) string {
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
if q := c.Query("token"); q != "" {
return q
}
return ""
}