veza/veza-backend-api/internal/services/upload_validator.go
2025-12-03 20:29:37 +01:00

332 lines
7.9 KiB
Go

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