package handlers import ( "context" "fmt" "mime/multipart" "net/http" "strings" "time" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" "veza-backend-api/internal/upload" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // UploadRequest requête pour upload de fichier // DEPRECATED: Use upload.StandardUploadRequest instead // INT-015: Kept for backward compatibility during migration type UploadRequest struct { TrackID string `form:"track_id" binding:"required"` FileType string `form:"file_type" binding:"required,oneof=audio image video"` Title string `form:"title" binding:"required,min=1,max=255"` Artist string `form:"artist" binding:"required,min=1,max=255"` Duration int `form:"duration" binding:"min=0"` Metadata string `form:"metadata"` } // UploadResponse réponse pour upload // DEPRECATED: Use upload.StandardUploadResponse instead // INT-015: Kept for backward compatibility during migration type UploadResponse struct { ID uuid.UUID `json:"id"` TrackID uuid.UUID `json:"track_id"` FileName string `json:"file_name"` FileSize int64 `json:"file_size"` FileType string `json:"file_type"` Checksum string `json:"checksum"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } // UploadValidatorInterface définit les méthodes nécessaires pour UploadValidator type UploadValidatorInterface interface { ValidateFile(ctx context.Context, fileHeader *multipart.FileHeader, fileType string) (*services.ValidationResult, error) GetFileTypeFromPath(filename string) string } // UploadAuditServiceInterface définit les méthodes nécessaires pour AuditService dans le contexte d'upload type UploadAuditServiceInterface interface { LogUpload(ctx context.Context, userID uuid.UUID, resourceID uuid.UUID, fileName string, fileSize int64, ipAddress, userAgent string) error LogDeletion(ctx context.Context, userID uuid.UUID, resource string, resourceID uuid.UUID, ipAddress, userAgent string) error } // TrackUploadServiceInterface définit les méthodes nécessaires pour TrackUploadService type TrackUploadServiceInterface interface { GetUploadStats(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error) // SEC-06: Return owner user ID for a given track/upload ID GetUploadOwnerID(ctx context.Context, trackID uuid.UUID) (uuid.UUID, error) } // UploadHandler gère les uploads de fichiers type UploadHandler struct { uploadValidator UploadValidatorInterface auditService UploadAuditServiceInterface trackUploadService TrackUploadServiceInterface 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, trackUploadService *services.TrackUploadService, logger *zap.Logger, maxConcurrentUploads int, ) *UploadHandler { if maxConcurrentUploads <= 0 { maxConcurrentUploads = 10 // Valeur par défaut } return &UploadHandler{ uploadValidator: uploadValidator, auditService: auditService, trackUploadService: trackUploadService, logger: logger, uploadSemaphore: make(chan struct{}, maxConcurrentUploads), // MOD-P2-005: Sémaphore bufferisé } } // NewUploadHandlerWithInterface crée un nouveau handler d'upload avec des interfaces (pour les tests) func NewUploadHandlerWithInterface( uploadValidator UploadValidatorInterface, auditService UploadAuditServiceInterface, trackUploadService TrackUploadServiceInterface, logger *zap.Logger, maxConcurrentUploads int, ) *UploadHandler { if maxConcurrentUploads <= 0 { maxConcurrentUploads = 10 // Valeur par défaut } return &UploadHandler{ uploadValidator: uploadValidator, auditService: auditService, trackUploadService: trackUploadService, logger: logger, uploadSemaphore: make(chan struct{}, maxConcurrentUploads), } } // 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 // INT-015: Use standardized error format c.JSON(http.StatusServiceUnavailable, gin.H{ "success": false, "error": gin.H{ "code": upload.ErrorCodeTooManyConcurrent, "message": "Too many concurrent uploads. Please try again later.", }, }) return } // Récupérer l'ID utilisateur depuis le contexte userIDInterface, exists := c.Get("user_id") if !exists { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated")) return } userID, ok := userIDInterface.(uuid.UUID) if !ok { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "Invalid user ID type")) return } // Parser la requête multipart var req UploadRequest if err := c.ShouldBind(&req); err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, err.Error())) return } trackID, err := uuid.Parse(req.TrackID) if err != nil { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "Invalid track ID")) return } // Récupérer le fichier fileHeader, err := c.FormFile("file") if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "No file provided")) return } // MOD-P1-001: Valider le fichier AVANT toute persistance (scan ClamAV inclus) validationResult, err := uh.uploadValidator.ValidateFile(c.Request.Context(), fileHeader, req.FileType) if err != nil { // MOD-P1-001-REFINEMENT: Détecter erreur ClamAV unavailable et retourner 503 if strings.Contains(err.Error(), "clamav_unavailable") { uh.logger.Warn("Upload rejected: ClamAV unavailable", zap.String("user_id", userID.String()), zap.String("file_name", fileHeader.Filename), zap.Error(err), ) c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Virus scanning service is temporarily unavailable", "message": "Uploads are disabled for security reasons until the scanning service is restored", "code": "SERVICE_UNAVAILABLE", }) return } uh.logger.Error("File validation failed", zap.Error(err), zap.String("user_id", userID.String()), zap.String("file_name", fileHeader.Filename), ) // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "File validation failed")) return } // MOD-P1-001: Détecter virus détecté (code 422) vs autres erreurs if validationResult.Quarantined || (err != nil && strings.Contains(err.Error(), "clamav_infected")) { uh.logger.Warn("File rejected: virus detected", zap.String("user_id", userID.String()), zap.String("file_name", fileHeader.Filename), zap.String("reason", validationResult.Error), ) c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "File rejected: virus detected", "details": validationResult.Error, "code": "VIRUS_DETECTED", }) return } // Vérifier si le fichier est valide if !validationResult.Valid { uh.logger.Warn("Invalid file uploaded", zap.String("user_id", userID.String()), zap.String("file_name", fileHeader.Filename), zap.String("error", validationResult.Error), ) // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, validationResult.Error)) return } // MOD-P1-001: Détecter erreur de scan ClamAV (timeout, connexion, etc.) if err != nil && strings.Contains(err.Error(), "clamav_scan_error") { uh.logger.Error("Upload rejected: ClamAV scan error", zap.String("user_id", userID.String()), zap.String("file_name", fileHeader.Filename), zap.Error(err), ) c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Virus scan failed", "message": "Unable to complete virus scan. Upload rejected for security.", "code": "SCAN_ERROR", }) return } // Créer l'enregistrement en base de données // Note: Dans un vrai environnement, il faudrait sauvegarder le fichier // et créer l'enregistrement dans la table tracks uploadID := uuid.New() // Log l'upload dans l'audit err = uh.auditService.LogUpload( c.Request.Context(), userID, trackID, fileHeader.Filename, validationResult.FileSize, c.ClientIP(), c.GetHeader("User-Agent"), ) if err != nil { uh.logger.Error("Failed to log upload audit", zap.Error(err), zap.String("user_id", userID.String()), ) // Ne pas faire échouer l'upload pour une erreur d'audit } uh.logger.Info("File uploaded successfully", zap.String("user_id", userID.String()), zap.String("upload_id", uploadID.String()), zap.String("file_name", fileHeader.Filename), zap.Int64("file_size", validationResult.FileSize), zap.String("file_type", validationResult.FileType), ) // INT-015: Return standardized upload response now := time.Now() virusScanResult := "clean" response := &upload.StandardUploadResponse{ ID: uploadID, TrackID: &trackID, FileName: fileHeader.Filename, FileSize: validationResult.FileSize, FileType: validationResult.FileType, MimeType: fileHeader.Header.Get("Content-Type"), Checksum: validationResult.Checksum, Status: upload.UploadStatusCompleted, Progress: 100, BytesUploaded: validationResult.FileSize, StoragePath: "", // Will be set by storage service StorageProvider: "s3", IsProcessed: false, VirusScanned: true, VirusScanResult: &virusScanResult, VirusScannedAt: &now, CreatedAt: now, UpdatedAt: now, } RespondSuccess(c, http.StatusCreated, response) } } // GetUploadStatus récupère le statut d'un upload // SEC-06: Ownership check — only the uploader can query status func (uh *UploadHandler) GetUploadStatus() gin.HandlerFunc { return func(c *gin.Context) { userIDInterface, exists := c.Get("user_id") if !exists { RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated")) return } _, ok := userIDInterface.(uuid.UUID) if !ok { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "Invalid user ID type")) return } uploadIDStr := c.Param("id") uploadID, err := uuid.Parse(uploadIDStr) if err != nil { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "Invalid upload ID")) return } // SEC-06: Verify ownership before returning status ownerID, _ := userIDInterface.(uuid.UUID) if uh.trackUploadService != nil { trackOwnerID, dbErr := uh.trackUploadService.GetUploadOwnerID(c.Request.Context(), uploadID) if dbErr != nil { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "Upload not found")) return } if trackOwnerID != ownerID { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "Upload not found")) return } } RespondSuccess(c, http.StatusOK, gin.H{ "id": uploadID, "status": "completed", "progress": 100, }) } } // DeleteUpload supprime un upload func (uh *UploadHandler) DeleteUpload() gin.HandlerFunc { return func(c *gin.Context) { // Récupérer l'ID utilisateur depuis le contexte userIDInterface, exists := c.Get("user_id") if !exists { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated")) return } userID, ok := userIDInterface.(uuid.UUID) if !ok { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "Invalid user ID type")) return } uploadIDStr := c.Param("id") uploadID, err := uuid.Parse(uploadIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "Invalid upload ID")) return } // Log la suppression dans l'audit err = uh.auditService.LogDeletion( c.Request.Context(), userID, "upload", uploadID, c.ClientIP(), c.GetHeader("User-Agent"), ) if err != nil { uh.logger.Error("Failed to log deletion audit", zap.Error(err), zap.String("user_id", userID.String()), ) } uh.logger.Info("Upload deleted", zap.String("user_id", userID.String()), zap.String("upload_id", uploadID.String()), ) RespondSuccess(c, http.StatusOK, gin.H{ "message": "Upload deleted successfully", }) } } // GetUploadStats récupère les statistiques d'upload // GET /api/v1/uploads/stats // BE-API-032: Implement upload stats endpoint func (uh *UploadHandler) GetUploadStats() gin.HandlerFunc { return func(c *gin.Context) { // Récupérer l'ID utilisateur depuis le contexte userID, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } // Récupérer les statistiques depuis la base de données if uh.trackUploadService == nil { uh.logger.Error("TrackUploadService not available") RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Upload stats service not available", nil)) return } stats, err := uh.trackUploadService.GetUploadStats(c.Request.Context(), userID) if err != nil { uh.logger.Error("Failed to get upload stats", zap.Error(err), zap.String("user_id", userID.String()), ) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get upload stats", err)) return } // BE-API-032: Standardize response format RespondSuccess(c, http.StatusOK, gin.H{ "user_id": userID, "stats": stats, }) } } // ValidateFileType valide le type de fichier func (uh *UploadHandler) ValidateFileType() gin.HandlerFunc { return func(c *gin.Context) { fileType := c.Query("type") if fileType == "" { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "File type parameter required")) return } // Vérifier si le type est supporté supportedTypes := []string{"audio", "image", "video"} isSupported := false for _, supportedType := range supportedTypes { if fileType == supportedType { isSupported = true break } } if !isSupported { c.JSON(http.StatusBadRequest, gin.H{ "error": "Unsupported file type", "supported_types": supportedTypes, }) return } RespondSuccess(c, http.StatusOK, gin.H{ "type": fileType, "supported": true, "supported_types": supportedTypes, }) } } // GetUploadLimits récupère les limites d'upload func (uh *UploadHandler) GetUploadLimits() gin.HandlerFunc { return func(c *gin.Context) { limits := map[string]interface{}{ "audio": map[string]interface{}{ "max_size": "100MB", "max_size_bytes": 100 * 1024 * 1024, "allowed_types": []string{ "audio/mpeg", "audio/mp3", "audio/wav", "audio/flac", "audio/aac", "audio/ogg", "audio/m4a", }, }, "image": map[string]interface{}{ "max_size": "10MB", "max_size_bytes": 10 * 1024 * 1024, "allowed_types": []string{ "image/jpeg", "image/png", "image/gif", "image/webp", }, }, "video": map[string]interface{}{ "max_size": "500MB", "max_size_bytes": 500 * 1024 * 1024, "allowed_types": []string{ "video/mp4", "video/webm", "video/ogg", "video/avi", }, }, } RespondSuccess(c, http.StatusOK, gin.H{ "limits": limits, }) } } // UploadProgress gère le suivi de progression d'upload func (uh *UploadHandler) UploadProgress() gin.HandlerFunc { return func(c *gin.Context) { uploadIDStr := c.Param("id") uploadID, err := uuid.Parse(uploadIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "Invalid upload ID")) return } // Récupérer la progression depuis la base de données // Note: Dans un vrai environnement, il faudrait interroger la DB progress := map[string]interface{}{ "upload_id": uploadID, "status": "completed", "progress": 100, "bytes_uploaded": 0, "total_bytes": 0, "estimated_time_remaining": 0, } RespondSuccess(c, http.StatusOK, progress) } } // BatchUpload gère les uploads multiples func (uh *UploadHandler) BatchUpload() gin.HandlerFunc { return func(c *gin.Context) { // Récupérer l'ID utilisateur depuis le contexte userIDInterface, exists := c.Get("user_id") if !exists { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated")) return } userID, ok := userIDInterface.(uuid.UUID) if !ok { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "Invalid user ID type")) return } // Parser le formulaire multipart form, err := c.MultipartForm() if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "Invalid multipart form")) return } files := form.File["files"] if len(files) == 0 { // MOD-P2-003: Utiliser AppError au lieu de gin.H RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "No files provided")) return } // Limiter le nombre de fichiers par batch maxFiles := 10 if len(files) > maxFiles { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Too many files. Maximum %d files per batch", maxFiles), }) return } var results []map[string]interface{} var errors []string for i, fileHeader := range files { // Déterminer le type de fichier à partir de l'extension fileType := uh.uploadValidator.GetFileTypeFromPath(fileHeader.Filename) if fileType == "unknown" { errors = append(errors, fmt.Sprintf("File %d (%s): Unknown file type", i+1, fileHeader.Filename)) continue } // MOD-P1-001: Valider le fichier AVANT toute persistance validationResult, err := uh.uploadValidator.ValidateFile(c.Request.Context(), fileHeader, fileType) if err != nil { // MOD-P1-001-REFINEMENT: Détecter erreur ClamAV unavailable if strings.Contains(err.Error(), "clamav_unavailable") { errors = append(errors, fmt.Sprintf("File %d (%s): Virus scanning service unavailable", i+1, fileHeader.Filename)) } else { errors = append(errors, fmt.Sprintf("File %d (%s): Validation error", i+1, fileHeader.Filename)) } continue } if !validationResult.Valid { errors = append(errors, fmt.Sprintf("File %d (%s): %s", i+1, fileHeader.Filename, validationResult.Error)) continue } // Créer le résultat result := map[string]interface{}{ "index": i + 1, "file_name": fileHeader.Filename, "file_size": validationResult.FileSize, "file_type": validationResult.FileType, "checksum": validationResult.Checksum, "status": "validated", "upload_id": uuid.New(), } results = append(results, result) } uh.logger.Info("Batch upload processed", zap.String("user_id", userID.String()), zap.Int("total_files", len(files)), zap.Int("successful", len(results)), zap.Int("errors", len(errors)), ) RespondSuccess(c, http.StatusOK, gin.H{ "message": "Batch upload processed", "results": results, "errors": errors, "summary": map[string]interface{}{ "total_files": len(files), "successful": len(results), "errors": len(errors), }, }) } }