stabilizing veza-backend-api: P1 & P2

This commit is contained in:
senke 2025-12-16 13:34:08 -05:00
parent 83e4463b4b
commit a7d463b8fd
18 changed files with 268 additions and 51 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // MOD-P2-006: Activer pprof pour profiling
"os"
"os/signal"
"syscall"

View file

@ -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))
}
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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})
}

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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()
}

View file

@ -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 == "" {

View file

@ -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)

View file

@ -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
}