mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AssetCacheControl sets optimal Cache-Control headers for static assets served by this server.
|
||||
// It only affects GET requests for well-known static prefixes and does not override
|
||||
// cache headers explicitly set by handlers downstream (they can still modify after c.Next if needed).
|
||||
func AssetCacheControl() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Only apply to GET requests
|
||||
if c.Request.Method == http.MethodGet {
|
||||
p := c.Request.URL.Path
|
||||
lower := strings.ToLower(p)
|
||||
switch {
|
||||
// Specific: YouTube channel static cache can be cached longer (content changes infrequently)
|
||||
case strings.HasPrefix(lower, "/cache/prefetch/") && strings.HasSuffix(lower, "youtube_channel.json"):
|
||||
c.Header("Cache-Control", "public, max-age=3600") // 1 hour
|
||||
case strings.HasPrefix(lower, "/dist/"):
|
||||
// Fingerprinted build assets should be cached for a year and immutable
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
case strings.HasPrefix(lower, "/uploads/"):
|
||||
// User uploads: cache for a week; allow clients to revalidate if replaced
|
||||
// Heuristic: if file name appears fingerprinted (e.g., .<hash>.ext), use longer cache
|
||||
base := filepath.Base(lower)
|
||||
if looksFingerprinted(base) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
c.Header("Cache-Control", "public, max-age=604800") // 7 days
|
||||
}
|
||||
case strings.HasPrefix(lower, "/cache/"):
|
||||
// Prefetched JSON and other generated cache files: short to medium cache
|
||||
c.Header("Cache-Control", "public, max-age=300") // 5 minutes
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// looksFingerprinted checks if a filename contains a long hex-like segment before the extension
|
||||
// e.g. logo.7eacd9f0bfa04928a9b6936140168f58.png
|
||||
func looksFingerprinted(name string) bool {
|
||||
dot := strings.LastIndexByte(name, '.')
|
||||
if dot <= 0 || dot >= len(name)-1 {
|
||||
return false
|
||||
}
|
||||
core := name[:dot]
|
||||
// Find final segment after last dot/underscore/hyphen in core
|
||||
lastSep := strings.LastIndexAny(core, "._-")
|
||||
if lastSep < 0 || lastSep+1 >= len(core) {
|
||||
return false
|
||||
}
|
||||
seg := core[lastSep+1:]
|
||||
if len(seg) < 16 { // require at least 16 chars to treat as a hash
|
||||
return false
|
||||
}
|
||||
// hex-like check
|
||||
for i := 0; i < len(seg); i++ {
|
||||
ch := seg[i]
|
||||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
// JWTAuth is a middleware that checks for a valid JWT token
|
||||
func JWTAuth(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Admin token shortcut: if a valid admin access token is provided, set admin role
|
||||
if config.AppConfig != nil && config.AppConfig.AdminAccessToken != "" {
|
||||
// Admin token shortcut (DEV/TEST ONLY): allow only outside production
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" && config.AppConfig.AdminAccessToken != "" {
|
||||
header := c.GetHeader("X-Admin-Token")
|
||||
if header != "" && header == config.AppConfig.AdminAccessToken {
|
||||
c.Set("userRole", "admin")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBContext adds a context with timeout to all database operations
|
||||
// This prevents queries from hanging indefinitely and exhausting connections
|
||||
func DBContext() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Create a context with timeout for this request's database operations
|
||||
// 15 seconds is generous for most queries while preventing indefinite hangs
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Store the context so controllers can use it with db.WithContext(ctx)
|
||||
c.Set("dbCtx", ctx)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetDBContext retrieves the database context from gin.Context
|
||||
// Returns a background context with timeout if not found
|
||||
func GetDBContext(c *gin.Context) context.Context {
|
||||
if ctx, exists := c.Get("dbCtx"); exists {
|
||||
if dbCtx, ok := ctx.(context.Context); ok {
|
||||
return dbCtx
|
||||
}
|
||||
}
|
||||
// Fallback with timeout
|
||||
ctx, _ := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CustomRecovery returns a middleware that recovers from panics and logs them
|
||||
func CustomRecovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// Get stack trace
|
||||
stack := string(debug.Stack())
|
||||
|
||||
// Log the panic
|
||||
requestID := GetRequestID(c)
|
||||
logger.Error("Panic recovered",
|
||||
"request_id", requestID,
|
||||
"error", fmt.Sprintf("%v", err),
|
||||
"stack", stack,
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
)
|
||||
|
||||
// Return error response
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"fotbal-club/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequestLogger logs a concise access log line per request with latency and identifiers.
|
||||
// It is lightweight and safe for production usage.
|
||||
func RequestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
method := c.Request.Method
|
||||
// Continue
|
||||
c.Next()
|
||||
// After handler
|
||||
status := c.Writer.Status()
|
||||
latency := time.Since(start)
|
||||
rid := c.GetString("request_id")
|
||||
// Try both userID keys used across codebase
|
||||
var uid any
|
||||
if v, ok := c.Get("userID"); ok {
|
||||
uid = v
|
||||
} else if v, ok := c.Get("user_id"); ok {
|
||||
uid = v
|
||||
}
|
||||
if uid != nil {
|
||||
logger.Info("%s %s => %d (%s) rid=%s uid=%v", method, path, status, latency, rid, uid)
|
||||
} else {
|
||||
logger.Info("%s %s => %d (%s) rid=%s", method, path, status, latency, rid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RequestSizeLimit limits the size of request bodies
|
||||
@@ -39,14 +38,15 @@ func ValidateContentType() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
|
||||
// Allow multipart for file uploads
|
||||
if strings.Contains(c.Request.URL.Path, "/upload") {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Allow multipart for uploads and image processing crop upload
|
||||
if strings.Contains(path, "/upload") || strings.Contains(path, "/image-processing/crop-upload") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Require JSON for API endpoints
|
||||
// Require JSON for other API endpoints
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{
|
||||
"error": "Content-Type must be application/json",
|
||||
@@ -75,10 +75,17 @@ func RequestID() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func generateRequestID() string {
|
||||
// Simple request ID generation
|
||||
b := make([]byte, 16)
|
||||
_, _ = io.ReadFull(bytes.NewReader([]byte(strings.Repeat("0123456789abcdef", 2))), b)
|
||||
return string(b)
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// GetRequestID retrieves the request ID from context
|
||||
func GetRequestID(c *gin.Context) string {
|
||||
if id, exists := c.Get("request_id"); exists {
|
||||
if requestID, ok := id.(string); ok {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SecurityAuditLog logs security-relevant events
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SmartCompression applies gzip compression intelligently
|
||||
// Skips compression for already compressed formats and small responses
|
||||
func SmartCompression() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip compression for already compressed formats
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if shouldSkipCompression(contentType) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Skip compression for small responses (< 1KB overhead not worth it)
|
||||
// This is handled by checking response size in writer
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSkipCompression checks if content type should skip compression
|
||||
func shouldSkipCompression(contentType string) bool {
|
||||
// Already compressed formats
|
||||
skipTypes := []string{
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/",
|
||||
"audio/",
|
||||
"application/zip",
|
||||
"application/x-zip",
|
||||
"application/x-gzip",
|
||||
"application/gzip",
|
||||
"application/x-compress",
|
||||
"application/pdf", // PDFs are already compressed
|
||||
}
|
||||
|
||||
for _, skip := range skipTypes {
|
||||
if strings.Contains(strings.ToLower(contentType), skip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -29,7 +29,11 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Strict Content-Security-Policy
|
||||
csp := buildCSP(config.AppConfig.AppEnv == "production")
|
||||
// Prefer configured CSP from environment/config, otherwise build a safe default
|
||||
csp := config.AppConfig.ContentSecurityPolicy
|
||||
if csp == "" {
|
||||
csp = buildCSP(config.AppConfig.AppEnv == "production")
|
||||
}
|
||||
c.Header("Content-Security-Policy", csp)
|
||||
|
||||
// Additional security headers
|
||||
@@ -46,13 +50,13 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
// buildCSP creates a strict Content-Security-Policy
|
||||
func buildCSP(production bool) string {
|
||||
if production {
|
||||
// Strict production CSP
|
||||
// Generic production CSP without hardcoded domains
|
||||
return "default-src 'self'; " +
|
||||
"script-src 'self' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
|
||||
"style-src 'self' https://fonts.googleapis.com; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com data:; " +
|
||||
"img-src 'self' data: https: blob:; " +
|
||||
"connect-src 'self' https://umami.tdvorak.dev https://zonerama.tdvorak.dev; " +
|
||||
"connect-src 'self' https:; " +
|
||||
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
@@ -61,9 +65,9 @@ func buildCSP(production bool) string {
|
||||
"upgrade-insecure-requests;"
|
||||
}
|
||||
|
||||
// Development CSP - slightly relaxed for local development
|
||||
// Development CSP - relaxed for local development
|
||||
return "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com data:; " +
|
||||
"img-src 'self' data: https: http: blob:; " +
|
||||
|
||||
Reference in New Issue
Block a user