veza/veza-backend-api/internal/handlers/upload.go
senke b28d0e7eac [T0-006] test(backend): Ajout tests pour frontend_log_handler
- Tests complets pour frontend_log_handler.go (12 tests)
- Tests couvrent NewFrontendLogHandler et ReceiveLog
- Tests pour tous les niveaux de log (DEBUG, INFO, WARN, ERROR)
- Tests pour gestion des erreurs et validation JSON
- Couverture actuelle: 30.6% (objectif: 80%)

Files: veza-backend-api/internal/handlers/frontend_log_handler_test.go
       VEZA_ROADMAP.json
Hours: 16 estimated, 23 actual
2026-01-04 01:44:22 +01:00

627 lines
20 KiB
Go

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)
}
// 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
func (uh *UploadHandler) GetUploadStatus() 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 le statut depuis la base de données
// Note: Dans un vrai environnement, il faudrait interroger la DB
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",
"image/svg+xml",
},
},
"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),
},
})
}
}