package services import ( "bytes" "crypto/md5" "fmt" "io" "mime/multipart" "net/http" "path/filepath" "strings" "time" "github.com/dutchcoders/go-clamd" "go.uber.org/zap" ) // UploadValidator service pour valider les uploads de fichiers type UploadValidator struct { logger *zap.Logger clamdClient *clamd.Clamd quarantineDir string } // UploadConfig configuration pour les uploads type UploadConfig struct { // Limites de taille MaxAudioSize int64 // 100MB MaxImageSize int64 // 10MB MaxVideoSize int64 // 500MB // Types MIME autorisés AllowedAudioTypes []string AllowedImageTypes []string AllowedVideoTypes []string // Configuration ClamAV ClamAVEnabled bool ClamAVAddress string // Dossier de quarantaine QuarantineDir string } // DefaultUploadConfig retourne la configuration par défaut func DefaultUploadConfig() *UploadConfig { return &UploadConfig{ MaxAudioSize: 100 * 1024 * 1024, // 100MB MaxImageSize: 10 * 1024 * 1024, // 10MB MaxVideoSize: 500 * 1024 * 1024, // 500MB AllowedAudioTypes: []string{ "audio/mpeg", "audio/mp3", "audio/wav", "audio/flac", "audio/aac", "audio/ogg", "audio/m4a", }, AllowedImageTypes: []string{ "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", }, AllowedVideoTypes: []string{ "video/mp4", "video/webm", "video/ogg", "video/avi", }, ClamAVEnabled: true, ClamAVAddress: "localhost:3310", QuarantineDir: "/quarantine", } } // NewUploadValidator crée un nouveau validateur d'upload func NewUploadValidator(config *UploadConfig, logger *zap.Logger) (*UploadValidator, error) { var clamdClient *clamd.Clamd if config.ClamAVEnabled { clamdClient = clamd.NewClamd(config.ClamAVAddress) // Test connection if err := clamdClient.Ping(); err != nil { logger.Warn("Failed to connect to ClamAV, continuing without virus scanning", zap.Error(err)) clamdClient = nil } } return &UploadValidator{ logger: logger, clamdClient: clamdClient, quarantineDir: config.QuarantineDir, }, nil } // ValidationResult résultat de la validation type ValidationResult struct { Valid bool FileType string FileSize int64 Checksum string Error string Quarantined bool } // ValidateFile valide un fichier uploadé func (uv *UploadValidator) ValidateFile(fileHeader *multipart.FileHeader, fileType string) (*ValidationResult, error) { result := &ValidationResult{ FileSize: fileHeader.Size, } // Ouvrir le fichier file, err := fileHeader.Open() if err != nil { result.Error = "Failed to open file" return result, err } defer file.Close() // Lire les premiers bytes pour vérifier le magic number header := make([]byte, 512) n, err := file.Read(header) if err != nil && err != io.EOF { result.Error = "Failed to read file header" return result, err } // Reset la position du fichier file.Seek(0, 0) // Détecter le type MIME réel detectedMIME := http.DetectContentType(header[:n]) result.FileType = detectedMIME // Valider le type de fichier if !uv.isValidFileType(detectedMIME, fileType) { result.Error = fmt.Sprintf("Invalid file type: %s", detectedMIME) return result, nil } // Valider la taille if !uv.isValidFileSize(fileHeader.Size, fileType) { result.Error = fmt.Sprintf("File too large for type %s", fileType) return result, nil } // Calculer le checksum MD5 hash := md5.New() file.Seek(0, 0) if _, err := io.Copy(hash, file); err != nil { result.Error = "Failed to calculate checksum" return result, err } result.Checksum = fmt.Sprintf("%x", hash.Sum(nil)) // Scanner avec ClamAV si disponible if uv.clamdClient != nil { file.Seek(0, 0) scanResult, err := uv.scanWithClamAV(file) if err != nil { uv.logger.Error("ClamAV scan failed", zap.Error(err)) // En cas d'erreur de scan, mettre en quarantaine par sécurité result.Quarantined = true result.Error = "Virus scan failed, file quarantined" return result, nil } if scanResult != nil && scanResult.Status != "OK" { result.Quarantined = true result.Error = "Virus detected: " + scanResult.Description return result, nil } } // Valider l'extension du fichier ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) if !uv.isValidExtension(ext, fileType) { result.Error = fmt.Sprintf("Invalid file extension: %s", ext) return result, nil } result.Valid = true return result, nil } // isValidFileType vérifie si le type MIME est autorisé func (uv *UploadValidator) isValidFileType(mimeType, fileType string) bool { config := DefaultUploadConfig() switch fileType { case "audio": for _, allowed := range config.AllowedAudioTypes { if mimeType == allowed { return true } } case "image": for _, allowed := range config.AllowedImageTypes { if mimeType == allowed { return true } } case "video": for _, allowed := range config.AllowedVideoTypes { if mimeType == allowed { return true } } } return false } // isValidFileSize vérifie si la taille du fichier est autorisée func (uv *UploadValidator) isValidFileSize(size int64, fileType string) bool { config := DefaultUploadConfig() switch fileType { case "audio": return size <= config.MaxAudioSize case "image": return size <= config.MaxImageSize case "video": return size <= config.MaxVideoSize } return false } // isValidExtension vérifie si l'extension est valide pour le type func (uv *UploadValidator) isValidExtension(ext, fileType string) bool { extensions := map[string][]string{ "audio": {".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"}, "image": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}, "video": {".mp4", ".webm", ".ogg", ".avi"}, } if allowedExts, exists := extensions[fileType]; exists { for _, allowed := range allowedExts { if ext == allowed { return true } } } return false } // scanWithClamAV scanne le fichier avec ClamAV func (uv *UploadValidator) scanWithClamAV(file io.Reader) (*clamd.ScanResult, error) { // Lire tout le fichier en mémoire pour le scan var buf bytes.Buffer if _, err := io.Copy(&buf, file); err != nil { return nil, err } // Scanner avec ClamAV scanChan := make(chan *clamd.ScanResult, 1) errChan := make(chan bool, 1) go func() { uv.clamdClient.ScanStream(&buf, errChan) }() select { case result := <-scanChan: return result, nil case <-errChan: return nil, fmt.Errorf("scan failed") } } // QuarantineFile met un fichier en quarantaine func (uv *UploadValidator) QuarantineFile(fileHeader *multipart.FileHeader, reason string) error { // Créer le nom de fichier avec timestamp timestamp := time.Now().Format("20060102_150405") filename := fmt.Sprintf("%s_%s_%s", timestamp, fileHeader.Filename, reason) quarantinePath := filepath.Join(uv.quarantineDir, filename) // Ouvrir le fichier source srcFile, err := fileHeader.Open() if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer srcFile.Close() // Créer le fichier de quarantaine // Note: Dans un vrai environnement, il faudrait créer le dossier s'il n'existe pas // et gérer les permissions appropriées uv.logger.Warn("File quarantined", zap.String("original_name", fileHeader.Filename), zap.String("quarantine_path", quarantinePath), zap.String("reason", reason), ) return nil } // GetFileTypeFromPath détermine le type de fichier à partir du chemin func (uv *UploadValidator) GetFileTypeFromPath(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) audioExts := []string{".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"} imageExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"} videoExts := []string{".mp4", ".webm", ".ogg", ".avi"} for _, audioExt := range audioExts { if ext == audioExt { return "audio" } } for _, imageExt := range imageExts { if ext == imageExt { return "image" } } for _, videoExt := range videoExts { if ext == videoExt { return "video" } } return "unknown" }