This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+70
View File
@@ -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
}
+2 -2
View File
@@ -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")
+37
View File
@@ -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
}
+43
View File
@@ -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()
}
}
+36
View File
@@ -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)
}
}
}
+17 -10
View File
@@ -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
}
+11 -7
View File
@@ -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:; " +