Second item of the v1.0.6 backlog. The "front 500MB vs back 100MB" mismatch
flagged in the v1.0.5 audit turned out to be a misread — every live pair
was already aligned (tracks 100/100, cloud 500/500, video 500/500). The
real bug is architectural: the same byte values were duplicated in five
places (`track/service.go`, `handlers/upload.go:GetUploadLimits`,
`handlers/education_handler.go`, `upload-modal/constants.ts`, and
`CloudUploadModal.tsx`), drifting silently as soon as anyone tuned one.
Backend — one canonical spec at `internal/config/upload_limits.go`:
* `AudioLimit`, `ImageLimit`, `VideoLimit` expose `Bytes()`, `MB()`,
`HumanReadable()`, `AllowedMIMEs` — read lazily from env
(`MAX_UPLOAD_AUDIO_MB`, `MAX_UPLOAD_IMAGE_MB`, `MAX_UPLOAD_VIDEO_MB`)
with defaults 100/10/500.
* Invalid / negative / zero env values fall back to the default;
unreadable config can't turn the limit off silently.
* `track.Service.maxFileSize`, `track_upload_handler.go` error string,
`education_handler.go` video gate, and `upload.go:GetUploadLimits`
all read from this single source. Changing `MAX_UPLOAD_AUDIO_MB`
retunes every path at once.
Frontend — new `useUploadLimits()` hook:
* Fetches GET `/api/v1/upload/limits` via react-query (5 min stale,
30 min gc), one retry, then silently falls back to baked-in
defaults that match the backend compile-time defaults so the
dropzone stays responsive even without the network round-trip.
* `useUploadModal.ts` replaces its hardcoded `MAX_FILE_SIZE`
constant with `useUploadLimits().audio.maxBytes`, and surfaces
`audioMaxHuman` up to `UploadModal` → `UploadModalDropzone` so
the "max 100 MB" label and the "too large" error toast both
display the live value.
* `MAX_FILE_SIZE` constant kept as pure fallback for pre-network
render (documented as such).
Tests
* 4 Go tests on `config.UploadLimit` (defaults, env override, invalid
env → fallback, non-empty MIME lists).
* 4 Vitest tests on `useUploadLimits` (sync fallback on first render,
typed mapping from server payload, partial-payload falls back
per-category, network failure keeps fallback).
* Existing `trackUpload.integration.test.tsx` (11 cases) still green.
Out of scope (tracked for later):
* `CloudUploadModal.tsx` still has its own 500MB hardcoded — cloud
uploads accept audio+zip+midi with a different category semantic
than the three in `/upload/limits`. Unifying those deserves its
own design pass, not a drive-by.
* No runtime refactor of admin-provided custom category limits —
the current tri-category split covers every upload we ship today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
618 lines
20 KiB
Go
618 lines
20 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/config"
|
|
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"`
|
|
}
|
|
|
|
// 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 returns the current upload size limits by category.
|
|
// v1.0.6: values come from config.AudioLimit/ImageLimit/VideoLimit which
|
|
// read env vars at request time so a single canonical source is served
|
|
// to every caller (backend services + frontend dropzone).
|
|
func (uh *UploadHandler) GetUploadLimits() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
categoryJSON := func(l config.UploadLimitMB) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"max_size": l.HumanReadable(),
|
|
"max_size_bytes": l.Bytes(),
|
|
"allowed_types": l.AllowedMIMEs,
|
|
}
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"limits": map[string]interface{}{
|
|
"audio": categoryJSON(config.AudioLimit),
|
|
"image": categoryJSON(config.ImageLimit),
|
|
"video": categoryJSON(config.VideoLimit),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
},
|
|
})
|
|
}
|
|
}
|