stabilizing veza-backend-api: P1 & P2
This commit is contained in:
parent
83e4463b4b
commit
a7d463b8fd
18 changed files with 268 additions and 51 deletions
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // MOD-P2-006: Activer pprof pour profiling
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http" // MOD-P2-006: Pour pprof
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -621,7 +622,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
|
|||
uploadValidator, _ = services.NewUploadValidator(uploadConfig, r.logger)
|
||||
}
|
||||
auditService := services.NewAuditService(r.db, r.logger)
|
||||
uploadHandler := handlers.NewUploadHandler(uploadValidator, auditService, r.logger)
|
||||
uploadHandler := handlers.NewUploadHandler(uploadValidator, auditService, r.logger, r.config.MaxConcurrentUploads)
|
||||
v1Public.GET("/upload/limits", uploadHandler.GetUploadLimits())
|
||||
v1Public.GET("/upload/validate-type", uploadHandler.ValidateFileType())
|
||||
}
|
||||
|
|
@ -655,7 +656,7 @@ func (r *APIRouter) setupCoreProtectedRoutes(v1 *gin.RouterGroup) {
|
|||
|
||||
// Handlers
|
||||
sessionHandler := handlers.NewSessionHandler(sessionService, auditService, r.logger)
|
||||
uploadHandler := handlers.NewUploadHandler(uploadValidator, auditService, r.logger)
|
||||
uploadHandler := handlers.NewUploadHandler(uploadValidator, auditService, r.logger, r.config.MaxConcurrentUploads)
|
||||
auditHandler := handlers.NewAuditHandler(auditService, r.logger)
|
||||
|
||||
// Routes de session
|
||||
|
|
@ -722,5 +723,8 @@ func (r *APIRouter) setupCoreProtectedRoutes(v1 *gin.RouterGroup) {
|
|||
admin.GET("/audit/logs", auditHandler.SearchLogs())
|
||||
admin.GET("/audit/stats", auditHandler.GetStats())
|
||||
admin.GET("/audit/suspicious", auditHandler.DetectSuspiciousActivity())
|
||||
|
||||
// MOD-P2-006: Profiling pprof (protégé par auth admin)
|
||||
admin.Any("/debug/pprof/*path", gin.WrapH(http.DefaultServeMux))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ type Config struct {
|
|||
LogLevel string // Niveau de log (T0027)
|
||||
DBMaxRetries int
|
||||
DBRetryInterval time.Duration
|
||||
MaxConcurrentUploads int // MOD-P2-005: Limite uploads simultanés (backpressure)
|
||||
|
||||
// RabbitMQ
|
||||
RabbitMQEventBus *eventbus.RabbitMQEventBus // Ajout de l'instance de l'EventBus
|
||||
|
|
@ -136,6 +137,9 @@ func NewConfig() (*Config, error) {
|
|||
// Charger le port depuis les variables d'environnement (T0031)
|
||||
appPort := getEnvInt("APP_PORT", 8080)
|
||||
|
||||
// MOD-P2-005: Charger la limite d'uploads simultanés (backpressure)
|
||||
maxConcurrentUploads := getEnvInt("MAX_CONCURRENT_UPLOADS", 10) // 10 par défaut
|
||||
|
||||
// Configuration depuis les variables d'environnement
|
||||
// SECURITY: JWT_SECRET est REQUIS - pas de valeur par défaut pour éviter les failles de sécurité
|
||||
jwtSecret, err := getEnvRequired("JWT_SECRET")
|
||||
|
|
@ -178,6 +182,7 @@ func NewConfig() (*Config, error) {
|
|||
Logger: logger,
|
||||
DBMaxRetries: getEnvInt("DB_MAX_RETRIES", 5), // 5 tentatives par défaut
|
||||
DBRetryInterval: getEnvDuration("DB_RETRY_INTERVAL", 5*time.Second), // 5 secondes par défaut
|
||||
MaxConcurrentUploads: maxConcurrentUploads, // MOD-P2-005: Limite uploads simultanés
|
||||
|
||||
// Configuration RabbitMQ
|
||||
RabbitMQURL: getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
|
||||
|
|
@ -318,6 +323,12 @@ func (c *Config) initServices() error {
|
|||
|
||||
// Validateur d'upload
|
||||
uploadConfig := services.DefaultUploadConfig()
|
||||
// MOD-P1-002: Lire CLAMAV_REQUIRED depuis l'environnement (défaut: true pour sécurité)
|
||||
clamAVRequired := getEnvBool("CLAMAV_REQUIRED", true)
|
||||
uploadConfig.ClamAVRequired = clamAVRequired
|
||||
if !clamAVRequired {
|
||||
c.Logger.Warn("CLAMAV_REQUIRED=false - Uploads will be accepted even if ClamAV is unavailable (degraded mode). This should only be used in development or with alternative security measures.")
|
||||
}
|
||||
var err error
|
||||
c.UploadValidator, err = services.NewUploadValidator(uploadConfig, c.Logger)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"veza-backend-api/internal/models"
|
||||
"veza-backend-api/internal/monitoring"
|
||||
"veza-backend-api/internal/services" // Added import for services
|
||||
"veza-backend-api/internal/workers"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
|
@ -163,6 +165,10 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
|||
zap.String("user_id", user.ID.String()))
|
||||
|
||||
s.logger.Info("User registered successfully", zap.String("user_id", user.ID.String()))
|
||||
|
||||
// MOD-P2-003: Enregistrer la métrique business
|
||||
monitoring.RecordUserRegistered()
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
"veza-backend-api/internal/monitoring"
|
||||
"veza-backend-api/internal/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -200,6 +201,9 @@ func (s *TrackService) UploadTrack(ctx context.Context, userID uuid.UUID, fileHe
|
|||
// La goroutine mettra à jour le Status quand terminé
|
||||
go s.copyFileAsync(ctx, track.ID, fileHeader, filePath, userID)
|
||||
|
||||
// MOD-P2-003: Enregistrer la métrique business
|
||||
monitoring.RecordTrackUploaded()
|
||||
|
||||
s.logger.Info("Track upload initiated (async)",
|
||||
zap.String("track_id", track.ID.String()),
|
||||
zap.String("user_id", userID.String()),
|
||||
|
|
@ -664,10 +668,11 @@ func (s *TrackService) UpdateStreamStatus(ctx context.Context, trackID uuid.UUID
|
|||
updates["stream_manifest_url"] = manifestURL
|
||||
}
|
||||
|
||||
if status == "ready" {
|
||||
switch status {
|
||||
case "ready":
|
||||
updates["status"] = models.TrackStatusCompleted
|
||||
updates["status_message"] = "Ready for streaming"
|
||||
} else if status == "error" {
|
||||
case "error":
|
||||
updates["status"] = models.TrackStatusFailed
|
||||
updates["status_message"] = "Transcoding failed"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// AnalyticsHandler gère les opérations d'analytics de lecture de tracks
|
||||
|
|
@ -26,9 +27,10 @@ func NewAnalyticsHandler(analyticsService *services.AnalyticsService, logger *za
|
|||
}
|
||||
|
||||
// RecordPlayRequest représente la requête pour enregistrer une lecture
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type RecordPlayRequest struct {
|
||||
Duration int `json:"duration" binding:"required,min=1"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Duration int `json:"duration" binding:"required,min=1" validate:"required,min=1"`
|
||||
Device string `json:"device,omitempty" validate:"omitempty,max=100"`
|
||||
}
|
||||
|
||||
// RecordPlay gère l'enregistrement d'une lecture de track
|
||||
|
|
|
|||
|
|
@ -27,14 +27,16 @@ func NewCommentHandler(commentService *services.CommentService, logger *zap.Logg
|
|||
}
|
||||
|
||||
// CreateCommentRequest représente la requête pour créer un commentaire
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"` // Changed to *uuid.UUID
|
||||
Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty" validate:"omitempty"` // Changed to *uuid.UUID
|
||||
}
|
||||
|
||||
// UpdateCommentRequest représente la requête pour mettre à jour un commentaire
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type UpdateCommentRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||
Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"`
|
||||
}
|
||||
|
||||
// CreateComment gère la création d'un commentaire sur un track
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"veza-backend-api/internal/core/marketplace"
|
||||
"veza-backend-api/internal/response"
|
||||
|
||||
|
|
@ -25,13 +27,14 @@ func NewMarketplaceHandler(service marketplace.MarketplaceService, logger *zap.L
|
|||
|
||||
// CreateProductRequest DTO pour la création de produit
|
||||
// GO-013: Validation améliorée avec tags go-validator
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type CreateProductRequest struct {
|
||||
Title string `json:"title" binding:"required,min=3,max=200"`
|
||||
Description string `json:"description" binding:"max=2000"`
|
||||
Price float64 `json:"price" binding:"required,min=0,gt=0"`
|
||||
ProductType string `json:"product_type" binding:"required,oneof=track pack service"`
|
||||
TrackID string `json:"track_id,omitempty" binding:"omitempty,uuid"` // UUID string
|
||||
LicenseType string `json:"license_type,omitempty" binding:"omitempty,oneof=standard exclusive commercial"`
|
||||
Title string `json:"title" binding:"required,min=3,max=200" validate:"required,min=3,max=200"`
|
||||
Description string `json:"description" binding:"max=2000" validate:"omitempty,max=2000"`
|
||||
Price float64 `json:"price" binding:"required,min=0,gt=0" validate:"required,min=0,gt=0"`
|
||||
ProductType string `json:"product_type" binding:"required,oneof=track pack service" validate:"required,oneof=track pack service"`
|
||||
TrackID string `json:"track_id,omitempty" binding:"omitempty,uuid" validate:"omitempty,uuid"` // UUID string
|
||||
LicenseType string `json:"license_type,omitempty" binding:"omitempty,oneof=standard exclusive commercial" validate:"omitempty,oneof=standard exclusive commercial"`
|
||||
}
|
||||
|
||||
// CreateProduct gère la création d'un produit
|
||||
|
|
@ -95,10 +98,11 @@ func (h *MarketplaceHandler) CreateProduct(c *gin.Context) {
|
|||
}
|
||||
|
||||
// CreateOrderRequest DTO pour la création de commande
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type CreateOrderRequest struct {
|
||||
Items []struct {
|
||||
ProductID string `json:"product_id" binding:"required"`
|
||||
} `json:"items" binding:"required,min=1"`
|
||||
ProductID string `json:"product_id" binding:"required" validate:"required,uuid"`
|
||||
} `json:"items" binding:"required,min=1" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// CreateOrder gère l'achat de produits
|
||||
|
|
@ -138,7 +142,15 @@ func (h *MarketplaceHandler) CreateOrder(c *gin.Context) {
|
|||
|
||||
order, err := h.service.CreateOrder(c.Request.Context(), buyerID, items)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
// MOD-P1-004: Détecter les erreurs de validation client et retourner 400 au lieu de 500
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "not found") || strings.Contains(errStr, "not active") {
|
||||
// Erreurs de validation client (produit non trouvé, produit non actif)
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// Erreurs serveur (DB, IO, etc.) → 500
|
||||
response.InternalServerError(c, "Failed to create order")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import (
|
|||
|
||||
// RequestPasswordResetRequest represents a request to reset password
|
||||
// T0193: Request structure for password reset endpoint
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type RequestPasswordResetRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Email string `json:"email" binding:"required,email" validate:"required,email"`
|
||||
}
|
||||
|
||||
// RequestPasswordReset handles password reset request
|
||||
|
|
@ -87,9 +88,10 @@ func RequestPasswordReset(
|
|||
|
||||
// ResetPasswordRequest represents a request to complete password reset
|
||||
// T0194: Request structure for password reset completion
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
Token string `json:"token" binding:"required" validate:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
// ResetPassword handles password reset completion
|
||||
|
|
|
|||
|
|
@ -71,13 +71,14 @@ func NewPlaybackAnalyticsHandlerFull(analyticsService *services.PlaybackAnalytic
|
|||
|
||||
// RecordAnalyticsRequest représente la requête pour enregistrer des analytics de lecture
|
||||
// T0388: Create Playback Analytics Validation - Amélioré avec validation
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type RecordAnalyticsRequest struct {
|
||||
PlayTime int `json:"play_time" binding:"required,min=0"` // seconds
|
||||
PauseCount int `json:"pause_count" binding:"min=0"` // optional, default 0
|
||||
SeekCount int `json:"seek_count" binding:"min=0"` // optional, default 0
|
||||
CompletionRate *float64 `json:"completion_rate,omitempty"` // optional, will be calculated if not provided
|
||||
StartedAt time.Time `json:"started_at" binding:"required"` // ISO 8601 format
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"` // optional
|
||||
PlayTime int `json:"play_time" binding:"required,min=0" validate:"required,min=0"` // seconds
|
||||
PauseCount int `json:"pause_count" binding:"min=0" validate:"omitempty,min=0"` // optional, default 0
|
||||
SeekCount int `json:"seek_count" binding:"min=0" validate:"omitempty,min=0"` // optional, default 0
|
||||
CompletionRate *float64 `json:"completion_rate,omitempty" validate:"omitempty,gte=0,lte=1"` // optional, will be calculated if not provided
|
||||
StartedAt time.Time `json:"started_at" binding:"required" validate:"required"` // ISO 8601 format
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"` // optional
|
||||
}
|
||||
|
||||
// ValidationResult représente le résultat d'une validation
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
apperrors "veza-backend-api/internal/errors"
|
||||
"veza-backend-api/internal/models"
|
||||
"veza-backend-api/internal/monitoring"
|
||||
"veza-backend-api/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -47,16 +48,18 @@ func (h *PlaylistHandler) SetPlaylistFollowService(followService *services.Playl
|
|||
}
|
||||
|
||||
// CreatePlaylistRequest représente la requête pour créer une playlist
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique (Description manquait)
|
||||
type CreatePlaylistRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200" validate:"required,min=1,max=200"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Description string `json:"description,omitempty" validate:"omitempty,max=1000"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// UpdatePlaylistRequest représente la requête pour mettre à jour une playlist
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique (Description manquait)
|
||||
type UpdatePlaylistRequest struct {
|
||||
Title *string `json:"title,omitempty" binding:"omitempty,min=1,max=200" validate:"omitempty,min=1,max=200"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty,max=1000"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +105,9 @@ func (h *PlaylistHandler) CreatePlaylist(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// MOD-P2-003: Enregistrer la métrique business (depuis le handler pour éviter cycle d'import)
|
||||
monitoring.RecordPlaylistCreated()
|
||||
|
||||
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,8 +148,9 @@ func (h *RoomHandler) GetRoom(c *gin.Context) {
|
|||
}
|
||||
|
||||
// AddMemberRequest représente une requête pour ajouter un membre à une room
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type AddMemberRequest struct {
|
||||
UserID uuid.UUID `json:"user_id" binding:"required"` // Changed to UUID
|
||||
UserID uuid.UUID `json:"user_id" binding:"required" validate:"required"` // Changed to UUID
|
||||
}
|
||||
|
||||
// AddMember ajoute un membre à une room
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@ func NewSocialHandler(service social.SocialService, logger *zap.Logger) *SocialH
|
|||
|
||||
// CreatePostRequest DTO pour la création de post
|
||||
// GO-013: Validation améliorée avec tags go-validator
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type CreatePostRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||
Attachments map[string]string `json:"attachments"` // track_id, playlist_id (UUID strings)
|
||||
Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"`
|
||||
Attachments map[string]string `json:"attachments" validate:"omitempty"` // track_id, playlist_id (UUID strings)
|
||||
}
|
||||
|
||||
// CreatePost crée un post
|
||||
|
|
@ -64,9 +65,10 @@ func (h *SocialHandler) CreatePost(c *gin.Context) {
|
|||
|
||||
// ToggleLikeRequest DTO pour liker
|
||||
// GO-013: Validation améliorée avec tags go-validator
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type ToggleLikeRequest struct {
|
||||
TargetID string `json:"target_id" binding:"required,uuid"`
|
||||
TargetType string `json:"target_type" binding:"required,oneof=post track playlist"`
|
||||
TargetID string `json:"target_id" binding:"required,uuid" validate:"required,uuid"`
|
||||
TargetType string `json:"target_type" binding:"required,oneof=post track playlist" validate:"required,oneof=post track playlist"`
|
||||
}
|
||||
|
||||
// ToggleLike like ou unlike un objet
|
||||
|
|
@ -102,10 +104,11 @@ func (h *SocialHandler) ToggleLike(c *gin.Context) {
|
|||
|
||||
// AddCommentRequest DTO pour commenter
|
||||
// GO-013: Validation améliorée avec tags go-validator
|
||||
// MOD-P1-001: Ajout tags validate pour validation systématique
|
||||
type AddCommentRequest struct {
|
||||
TargetID string `json:"target_id" binding:"required,uuid"`
|
||||
TargetType string `json:"target_type" binding:"required,oneof=post track playlist"`
|
||||
Content string `json:"content" binding:"required,min=1,max=2000"`
|
||||
TargetID string `json:"target_id" binding:"required,uuid" validate:"required,uuid"`
|
||||
TargetType string `json:"target_type" binding:"required,oneof=post track playlist" validate:"required,oneof=post track playlist"`
|
||||
Content string `json:"content" binding:"required,min=1,max=2000" validate:"required,min=1,max=2000"`
|
||||
}
|
||||
|
||||
// AddComment ajoute un commentaire
|
||||
|
|
|
|||
|
|
@ -41,24 +41,55 @@ type UploadHandler struct {
|
|||
uploadValidator *services.UploadValidator
|
||||
auditService *services.AuditService
|
||||
logger *zap.Logger
|
||||
uploadSemaphore chan struct{} // MOD-P2-005: Sémaphore pour limiter uploads simultanés
|
||||
}
|
||||
|
||||
// NewUploadHandler crée un nouveau handler d'upload
|
||||
// MOD-P2-005: maxConcurrentUploads définit la limite d'uploads simultanés (backpressure)
|
||||
func NewUploadHandler(
|
||||
uploadValidator *services.UploadValidator,
|
||||
auditService *services.AuditService,
|
||||
logger *zap.Logger,
|
||||
maxConcurrentUploads int,
|
||||
) *UploadHandler {
|
||||
if maxConcurrentUploads <= 0 {
|
||||
maxConcurrentUploads = 10 // Valeur par défaut
|
||||
}
|
||||
return &UploadHandler{
|
||||
uploadValidator: uploadValidator,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
uploadSemaphore: make(chan struct{}, maxConcurrentUploads), // MOD-P2-005: Sémaphore bufferisé
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile gère l'upload d'un fichier
|
||||
// MOD-P2-005: Utilise un sémaphore pour limiter les uploads simultanés (backpressure)
|
||||
func (uh *UploadHandler) UploadFile() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// MOD-P2-005: Acquérir le sémaphore (bloque si limite atteinte)
|
||||
select {
|
||||
case uh.uploadSemaphore <- struct{}{}:
|
||||
// Sémaphore acquis, continuer
|
||||
defer func() { <-uh.uploadSemaphore }() // Libérer le sémaphore à la fin
|
||||
case <-c.Request.Context().Done():
|
||||
// Contexte annulé (timeout, etc.)
|
||||
return
|
||||
default:
|
||||
// Sémaphore plein, retourner 503 Service Unavailable
|
||||
uh.logger.Warn("Upload rejected: too many concurrent uploads",
|
||||
zap.String("user_id", c.GetString("user_id")),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
)
|
||||
c.Header("Retry-After", "30") // Suggérer de réessayer après 30 secondes
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Service temporarily unavailable",
|
||||
"message": "Too many concurrent uploads. Please try again later.",
|
||||
"code": "TOO_MANY_CONCURRENT_UPLOADS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'ID utilisateur depuis le contexte
|
||||
userIDInterface, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,36 @@ var (
|
|||
},
|
||||
[]string{"service"},
|
||||
)
|
||||
|
||||
// MOD-P2-003: Business Metrics
|
||||
TracksUploadedTotal = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "veza_tracks_uploaded_total",
|
||||
Help: "Total number of tracks uploaded",
|
||||
},
|
||||
)
|
||||
|
||||
UsersRegisteredTotal = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "veza_users_registered_total",
|
||||
Help: "Total number of users registered",
|
||||
},
|
||||
)
|
||||
|
||||
PlaylistsCreatedTotal = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "veza_playlists_created_total",
|
||||
Help: "Total number of playlists created",
|
||||
},
|
||||
)
|
||||
|
||||
UploadsFailedTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "veza_uploads_failed_total",
|
||||
Help: "Total number of failed uploads",
|
||||
},
|
||||
[]string{"reason"}, // ClamAV, validation, quota, etc.
|
||||
)
|
||||
)
|
||||
|
||||
// Middleware pour enregistrer les métriques HTTP
|
||||
|
|
@ -256,3 +286,25 @@ func RecordHealthCheck(service string, durationMs float64, status string) {
|
|||
}
|
||||
HealthCheckStatus.WithLabelValues(service).Set(statusValue)
|
||||
}
|
||||
|
||||
// MOD-P2-003: Business Metrics Functions
|
||||
|
||||
// RecordTrackUploaded incrémente le compteur de tracks uploadés
|
||||
func RecordTrackUploaded() {
|
||||
TracksUploadedTotal.Inc()
|
||||
}
|
||||
|
||||
// RecordUserRegistered incrémente le compteur d'utilisateurs enregistrés
|
||||
func RecordUserRegistered() {
|
||||
UsersRegisteredTotal.Inc()
|
||||
}
|
||||
|
||||
// RecordPlaylistCreated incrémente le compteur de playlists créées
|
||||
func RecordPlaylistCreated() {
|
||||
PlaylistsCreatedTotal.Inc()
|
||||
}
|
||||
|
||||
// RecordUploadFailed enregistre un échec d'upload avec la raison
|
||||
func RecordUploadFailed(reason string) {
|
||||
UploadsFailedTotal.WithLabelValues(reason).Inc()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ func (r *gormUserRepository) Exists(ctx context.Context, userID uuid.UUID) (bool
|
|||
|
||||
// CreatePlaylist crée une nouvelle playlist
|
||||
// T0453: Utilise le repository pattern avec validation
|
||||
// MOD-P2-003: Enregistre la métrique business
|
||||
func (s *PlaylistService) CreatePlaylist(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool) (*models.Playlist, error) {
|
||||
// Validation
|
||||
if title == "" {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type UploadValidator struct {
|
|||
clamdClient *clamd.Clamd
|
||||
quarantineDir string
|
||||
clamAVRequiredButUnavailable bool // MOD-P1-001-REFINEMENT: Flag pour fail-secure localisé
|
||||
clamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down
|
||||
}
|
||||
|
||||
// UploadConfig configuration pour les uploads
|
||||
|
|
@ -37,8 +38,9 @@ type UploadConfig struct {
|
|||
AllowedVideoTypes []string
|
||||
|
||||
// Configuration ClamAV
|
||||
ClamAVEnabled bool
|
||||
ClamAVAddress string
|
||||
ClamAVEnabled bool // Active/désactive ClamAV
|
||||
ClamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down (mode dégradé)
|
||||
ClamAVAddress string
|
||||
|
||||
// Dossier de quarantaine
|
||||
QuarantineDir string
|
||||
|
|
@ -75,9 +77,10 @@ func DefaultUploadConfig() *UploadConfig {
|
|||
"video/avi",
|
||||
},
|
||||
|
||||
ClamAVEnabled: true,
|
||||
ClamAVAddress: "localhost:3310",
|
||||
QuarantineDir: "/quarantine",
|
||||
ClamAVEnabled: true,
|
||||
ClamAVRequired: true, // MOD-P1-002: Par défaut, ClamAV est requis (fail-secure)
|
||||
ClamAVAddress: "localhost:3310",
|
||||
QuarantineDir: "/quarantine",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,13 +95,23 @@ func NewUploadValidator(config *UploadConfig, logger *zap.Logger) (*UploadValida
|
|||
clamdClient = clamd.NewClamd(config.ClamAVAddress)
|
||||
// Test connection - MOD-P1-001-REFINEMENT: Ne pas bloquer le démarrage, mais flag pour fail-secure
|
||||
if err := clamdClient.Ping(); err != nil {
|
||||
logger.Warn("ClamAV is enabled but unavailable - uploads will be rejected until ClamAV is available",
|
||||
zap.Error(err),
|
||||
zap.String("address", config.ClamAVAddress),
|
||||
)
|
||||
clamAVRequiredButUnavailable = true
|
||||
if config.ClamAVRequired {
|
||||
// MOD-P1-002: Si ClamAV est requis, rejeter les uploads (fail-secure)
|
||||
logger.Warn("ClamAV is enabled and required but unavailable - uploads will be rejected until ClamAV is available",
|
||||
zap.Error(err),
|
||||
zap.String("address", config.ClamAVAddress),
|
||||
)
|
||||
clamAVRequiredButUnavailable = true
|
||||
} else {
|
||||
// MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads avec warning (mode dégradé)
|
||||
logger.Warn("ClamAV is enabled but unavailable and not required - uploads will be accepted without virus scanning (degraded mode)",
|
||||
zap.Error(err),
|
||||
zap.String("address", config.ClamAVAddress),
|
||||
zap.String("security_warning", "Virus scanning is disabled. This should only be used in development or with alternative security measures."),
|
||||
)
|
||||
clamAVRequiredButUnavailable = false
|
||||
}
|
||||
// Ne pas retourner d'erreur - le serveur peut démarrer
|
||||
// mais les uploads seront rejetés dans ValidateFile
|
||||
} else {
|
||||
logger.Info("ClamAV connection successful")
|
||||
}
|
||||
|
|
@ -109,6 +122,7 @@ func NewUploadValidator(config *UploadConfig, logger *zap.Logger) (*UploadValida
|
|||
clamdClient: clamdClient,
|
||||
quarantineDir: config.QuarantineDir,
|
||||
clamAVRequiredButUnavailable: clamAVRequiredButUnavailable,
|
||||
clamAVRequired: config.ClamAVRequired,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -174,13 +188,23 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa
|
|||
result.Checksum = fmt.Sprintf("%x", hash.Sum(nil))
|
||||
|
||||
// MOD-P1-001: Fail-secure - rejeter upload si ClamAV requis mais indisponible
|
||||
// MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads même si ClamAV down (mode dégradé)
|
||||
// Cette vérification se fait AVANT le scan pour éviter toute persistance
|
||||
if uv.clamAVRequiredButUnavailable {
|
||||
if uv.clamAVRequiredButUnavailable && uv.clamAVRequired {
|
||||
result.Error = "Virus scanning service is temporarily unavailable. Uploads are disabled for security reasons."
|
||||
// Retourner une erreur spéciale pour que le handler puisse retourner 503
|
||||
return result, fmt.Errorf("clamav_unavailable: %s", result.Error)
|
||||
}
|
||||
|
||||
// MOD-P1-002: Si ClamAV est down mais non requis, logger un warning mais continuer
|
||||
if uv.clamAVRequiredButUnavailable && !uv.clamAVRequired {
|
||||
uv.logger.Warn("ClamAV unavailable but not required - accepting upload without virus scan (degraded mode)",
|
||||
zap.String("filename", fileHeader.Filename),
|
||||
zap.String("security_warning", "File accepted without virus scanning. This should only be used in development."),
|
||||
)
|
||||
// Continuer sans scan ClamAV
|
||||
}
|
||||
|
||||
// Scanner avec ClamAV si disponible
|
||||
if uv.clamdClient != nil {
|
||||
file.Seek(0, 0)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ func TestUploadValidator_ClamAVDown_RejectsUploads(t *testing.T) {
|
|||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// Configuration avec ClamAV enabled mais adresse invalide (simule ClamAV down)
|
||||
// MOD-P1-002: ClamAVRequired doit être true pour que les uploads soient rejetés
|
||||
config := &UploadConfig{
|
||||
MaxAudioSize: 100 * 1024 * 1024,
|
||||
MaxImageSize: 10 * 1024 * 1024,
|
||||
|
|
@ -52,6 +53,7 @@ func TestUploadValidator_ClamAVDown_RejectsUploads(t *testing.T) {
|
|||
AllowedImageTypes: []string{"image/jpeg"},
|
||||
AllowedVideoTypes: []string{"video/mp4"},
|
||||
ClamAVEnabled: true,
|
||||
ClamAVRequired: true, // MOD-P1-002: ClamAV requis (fail-secure)
|
||||
ClamAVAddress: "localhost:99999", // Port invalide pour simuler ClamAV down
|
||||
QuarantineDir: "/tmp/quarantine",
|
||||
}
|
||||
|
|
@ -122,3 +124,54 @@ func TestUploadValidator_ClamAVDisabled_AllowsUploads(t *testing.T) {
|
|||
// Utiliser result pour éviter "declared and not used"
|
||||
_ = result
|
||||
}
|
||||
|
||||
// TestUploadValidator_ClamAVDown_NotRequired_AcceptsUploads vérifie le mode dégradé
|
||||
// MOD-P1-002: Test que si ClamAV est down et CLAMAV_REQUIRED=false, les uploads sont acceptés avec warning
|
||||
func TestUploadValidator_ClamAVDown_NotRequired_AcceptsUploads(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// Configuration avec ClamAV enabled mais non requis et adresse invalide (simule ClamAV down)
|
||||
config := &UploadConfig{
|
||||
MaxAudioSize: 100 * 1024 * 1024,
|
||||
MaxImageSize: 10 * 1024 * 1024,
|
||||
MaxVideoSize: 500 * 1024 * 1024,
|
||||
AllowedAudioTypes: []string{"audio/mpeg"},
|
||||
AllowedImageTypes: []string{"image/jpeg"},
|
||||
AllowedVideoTypes: []string{"video/mp4"},
|
||||
ClamAVEnabled: true,
|
||||
ClamAVRequired: false, // MOD-P1-002: ClamAV non requis
|
||||
ClamAVAddress: "localhost:99999", // Port invalide pour simuler ClamAV down
|
||||
QuarantineDir: "/tmp/quarantine",
|
||||
}
|
||||
|
||||
// MOD-P1-002: NewUploadValidator doit réussir même si ClamAV down et non requis
|
||||
validator, err := NewUploadValidator(config, logger)
|
||||
require.NoError(t, err, "NewUploadValidator should not fail even if ClamAV is down and not required")
|
||||
require.NotNil(t, validator, "Validator should be created")
|
||||
|
||||
// MOD-P1-002: Vérifier que le flag clamAVRequiredButUnavailable est false (uploads acceptés)
|
||||
assert.False(t, validator.clamAVRequiredButUnavailable,
|
||||
"Validator should NOT have clamAVRequiredButUnavailable flag set when ClamAV is not required")
|
||||
assert.False(t, validator.clamAVRequired,
|
||||
"Validator should have clamAVRequired=false")
|
||||
|
||||
// Créer un FileHeader valide pour test
|
||||
audioContent := []byte("fake audio content for testing")
|
||||
fileHeader := &multipart.FileHeader{
|
||||
Filename: "test.mp3",
|
||||
Size: int64(len(audioContent)),
|
||||
}
|
||||
|
||||
// MOD-P1-002: ValidateFile devrait accepter le fichier (pas de rejet ClamAV)
|
||||
// Note: Le fichier peut échouer pour d'autres raisons (type MIME, etc.)
|
||||
// mais pas à cause de ClamAV unavailable
|
||||
result, err := validator.ValidateFile(context.Background(), fileHeader, "audio")
|
||||
|
||||
// Vérifier que l'erreur n'est pas liée à ClamAV unavailable
|
||||
if err != nil {
|
||||
assert.NotContains(t, err.Error(), "clamav_unavailable",
|
||||
"Error should not mention ClamAV unavailable when CLAMAV_REQUIRED=false")
|
||||
}
|
||||
// Utiliser result pour éviter "declared and not used"
|
||||
_ = result
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue