diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index b279492e..8cd984e3 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -10,8 +10,6 @@ import ( "log" "mime/multipart" "net/http" - "os" - "path/filepath" "strconv" "strings" @@ -142,20 +140,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { return mimeType, nil } -func sanitizeFilePath(path string) (string, error) { - cleaned := filepath.Clean(path) - - if strings.Contains(cleaned, "..") { - return "", fmt.Errorf("path traversal détecté") - } - - if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") { - return "", fmt.Errorf("chemin invalide") - } - - return cleaned, nil -} - func CreateProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -324,12 +308,13 @@ func CreateProduct(c *gin.Context) { cleanProductName := cleanFileName(product.Name) uploadedMedia := []models.Media{} - savedFiles := []string{} + savedFiles := []models.Media{} + storage := c.MustGet("storage").(services.Storage) var totalSize int64 = 0 for i, fileHeader := range files { if fileHeader.Size > MaxFileSize { - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)), @@ -339,7 +324,7 @@ func CreateProduct(c *gin.Context) { totalSize += fileHeader.Size if totalSize > MaxTotalUploadSize { - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)), @@ -352,7 +337,7 @@ func CreateProduct(c *gin.Context) { mimeType, err := validateFileMimeType(fileHeader) if err != nil { log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err) - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"}) return @@ -364,7 +349,7 @@ func CreateProduct(c *gin.Context) { } else if strings.HasPrefix(mimeType, "video/") { mediaType = "video" } else { - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"}) return @@ -372,46 +357,27 @@ func CreateProduct(c *gin.Context) { uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename) - destFolder := filepath.Join("uploads", mediaType+"s") - if err := os.MkdirAll(destFolder, 0750); err != nil { - log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err) - rollbackFiles(savedFiles) - database.DeleteProduct(product.ID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"}) - return - } - - filePath := filepath.Join(destFolder, uniqueFileName) - - safeFilePath, err := sanitizeFilePath(filePath) + mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName) if err != nil { - log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err) - rollbackFiles(savedFiles) - database.DeleteProduct(product.ID) - c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"}) - return - } - - if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil { log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err) - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"}) return } - savedFiles = append(savedFiles, safeFilePath) + savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey}) - mediaURL := "/" + filepath.ToSlash(safeFilePath) media := models.Media{ ProductID: product.ID, Type: mediaType, URL: mediaURL, + Key: mediaKey, } if err := database.CreateMedia(&media); err != nil { log.Printf("❌ [CreateProduct] Erreur DB média: %v", err) - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) return @@ -736,6 +702,13 @@ func DeleteMedia(c *gin.Context) { } else { log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key) } + } else { + localStorage := services.NewLocalStorage("uploads") + if err := localStorage.Delete(media.URL, ""); err != nil { + log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err) + } else { + log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL) + } } c.JSON(http.StatusOK, gin.H{ @@ -746,7 +719,6 @@ func DeleteMedia(c *gin.Context) { func UploadMedia(c *gin.Context) { database := c.MustGet("database").(*db.Database) - s3Service := c.MustGet("s3Service").(*services.S3Service) username, err := safeGetUsername(c) if err != nil { @@ -816,27 +788,28 @@ func UploadMedia(c *gin.Context) { cleanProductName := cleanFileName(productName) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename) + storage := c.MustGet("storage").(services.Storage) folder := fileType + "s" - key, err := s3Service.UploadFileWithName(file, folder, uniqueFileName) + mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName) if err != nil { - log.Printf("❌ [UploadMedia] Erreur upload RustFS: %v", err) + log.Printf("❌ [UploadMedia] Erreur upload: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"}) return } - log.Printf("✅ [UploadMedia] Fichier uploadé sur RustFS: %s", key) + log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL) media := models.Media{ ProductID: productID, Type: fileType, - URL: "/media/" + key, - Key: key, + URL: mediaURL, + Key: mediaKey, } err = database.CreateMedia(&media) if err != nil { - if delErr := s3Service.DeleteFile(key); delErr != nil { - log.Printf("⚠️ [UploadMedia] Échec rollback RustFS (clé: %s): %v", key, delErr) + if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil { + log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr) } log.Printf("❌ [UploadMedia] Erreur DB: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) @@ -948,17 +921,17 @@ func DeleteProduct(c *gin.Context) { return } + s3Service := c.MustGet("s3Service").(*services.S3Service) + localStorage := services.NewLocalStorage("uploads") for _, media := range mediaList { - filePath := strings.TrimPrefix(media.URL, "/") - - safeFilePath, err := sanitizeFilePath(filePath) - if err != nil { - log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err) - continue - } - - if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) { - log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err) + if media.Key != "" { + if err := s3Service.DeleteFile(media.Key); err != nil { + log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err) + } + } else { + if err := localStorage.Delete(media.URL, ""); err != nil { + log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err) + } } } @@ -978,13 +951,11 @@ func DeleteProduct(c *gin.Context) { }) } -func rollbackFiles(files []string) { - for _, file := range files { - safeFilePath, err := sanitizeFilePath(file) - if err != nil { - continue +func rollbackFiles(storage services.Storage, files []models.Media) { + for _, f := range files { + if err := storage.Delete(f.URL, f.Key); err != nil { + log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err) } - os.Remove(safeFilePath) } } diff --git a/backend/gestion/main.go b/backend/gestion/main.go index bfc292f8..86b5ef73 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -105,6 +105,16 @@ func main() { log.Fatalf("erreur init S3: %v", err) } + var storage services.Storage + switch os.Getenv("STORAGE_DRIVER") { + case "s3": + storage = services.NewS3Storage(s3Service) + log.Println("✅ Storage driver: s3 (RustFS)") + default: + storage = services.NewLocalStorage("uploads") + log.Println("✅ Storage driver: local") + } + log.Println("") log.Println("🧹 Démarrage du nettoyage des commandes invalides...") removed, err := database.CleanupInvalidQueueCommands() @@ -167,6 +177,7 @@ func main() { c.Set("database", database) c.Set("geoService", geoService) c.Set("s3Service", s3Service) + c.Set("storage", storage) c.Next() }) diff --git a/backend/gestion/services/storage.go b/backend/gestion/services/storage.go new file mode 100644 index 00000000..e9df2c14 --- /dev/null +++ b/backend/gestion/services/storage.go @@ -0,0 +1,105 @@ +package services + +import ( + "fmt" + "gestion/utils" + "io" + "mime/multipart" + "os" + "path/filepath" +) + +// Storage abstrait l'emplacement de stockage des médias produits (local ou S3), +// pour que tous les points d'upload/suppression respectent le même driver. +type Storage interface { + // Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL) + // et la clé interne (vide pour local, clé S3 sinon — models.Media.Key). + Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) + // Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média : + // chaque implémentation ignore celui qui ne la concerne pas. + Delete(url string, key string) error +} + +// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads"). +type LocalStorage struct { + baseDir string +} + +func NewLocalStorage(baseDir string) *LocalStorage { + return &LocalStorage{baseDir: baseDir} +} + +func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) { + destFolder := filepath.Join(s.baseDir, folder) + if err := os.MkdirAll(destFolder, 0750); err != nil { + return "", "", fmt.Errorf("erreur création dossier: %w", err) + } + + filePath := filepath.Join(destFolder, fileName) + safeFilePath, err := utils.SanitizeFilePath(filePath) + if err != nil { + return "", "", fmt.Errorf("chemin invalide: %w", err) + } + + src, err := fileHeader.Open() + if err != nil { + return "", "", fmt.Errorf("erreur ouverture fichier: %w", err) + } + defer src.Close() + + dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640) + if err != nil { + return "", "", fmt.Errorf("erreur création fichier: %w", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + os.Remove(safeFilePath) + return "", "", fmt.Errorf("erreur écriture fichier: %w", err) + } + + return "/" + filepath.ToSlash(safeFilePath), "", nil +} + +func (s *LocalStorage) Delete(url string, key string) error { + filePath := "" + if len(url) > 0 && url[0] == '/' { + filePath = url[1:] + } else { + filePath = url + } + + safeFilePath, err := utils.SanitizeFilePath(filePath) + if err != nil { + return fmt.Errorf("chemin invalide: %w", err) + } + + if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("erreur suppression: %w", err) + } + return nil +} + +// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage. +type S3Storage struct { + s3 *S3Service +} + +func NewS3Storage(s3 *S3Service) *S3Storage { + return &S3Storage{s3: s3} +} + +func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) { + key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName) + if err != nil { + return "", "", err + } + return "/media/" + key, key, nil +} + +func (s *S3Storage) Delete(url string, key string) error { + if key == "" { + return fmt.Errorf("clé S3 manquante pour suppression") + } + return s.s3.DeleteFile(key) +} diff --git a/backend/gestion/utils/utils.go b/backend/gestion/utils/utils.go index ce42fb62..8fe6ab13 100644 --- a/backend/gestion/utils/utils.go +++ b/backend/gestion/utils/utils.go @@ -5,6 +5,7 @@ import ( "fmt" "math/big" "path/filepath" + "strings" ) func GenerateUniqueFileName(productName string, originalFileName string) string { @@ -13,3 +14,19 @@ func GenerateUniqueFileName(productName string, originalFileName string) string ext := filepath.Ext(originalFileName) return fmt.Sprintf("%s_%s%s", productName, randomString, ext) } + +// SanitizeFilePath valide qu'un chemin de fichier local reste bien dans le +// dossier uploads/ et ne contient pas de tentative de path traversal. +func SanitizeFilePath(path string) (string, error) { + cleaned := filepath.Clean(path) + + if strings.Contains(cleaned, "..") { + return "", fmt.Errorf("path traversal détecté") + } + + if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") { + return "", fmt.Errorf("chemin invalide") + } + + return cleaned, nil +} diff --git a/docker-pre-prod/.env.example b/docker-pre-prod/.env.example index 389dfacb..8d60a22a 100644 --- a/docker-pre-prod/.env.example +++ b/docker-pre-prod/.env.example @@ -24,3 +24,11 @@ BACKEND_LINK_SECRET=change_me_internal_secret API_PORT=8080 FRONTEND_PORT=5173 GIN_MODE=release +# STORAGE_DRIVER=local (defaut, stockage disque via le volume backend_uploads) ou s3 (RustFS) +STORAGE_DRIVER=local +# Requis uniquement si STORAGE_DRIVER=s3 +S3_REGION=us-east-1 +S3_BUCKET= +S3_ENDPOINT= +RUSTFS_ACCESS_KEY= +RUSTFS_SECRET_KEY= diff --git a/docker-pre-prod/docker-compose-pre-prod.yml b/docker-pre-prod/docker-compose-pre-prod.yml index 0a4c84d8..937a37dd 100644 --- a/docker-pre-prod/docker-compose-pre-prod.yml +++ b/docker-pre-prod/docker-compose-pre-prod.yml @@ -35,6 +35,12 @@ services: - LBTELEGRAM_BOT1_USERNAME=${LBTELEGRAM_BOT1_USERNAME:-GetRezStealer_bot} - LBTELEGRAM_BOT2_USERNAME=${LBTELEGRAM_BOT2_USERNAME:-rezDJDFJSFUltraFast_bot} - BACKEND_LINK_SECRET=${BACKEND_LINK_SECRET:-change_me_internal_secret} + - STORAGE_DRIVER=${STORAGE_DRIVER:-local} + - S3_REGION=${S3_REGION:-} + - S3_BUCKET=${S3_BUCKET:-} + - S3_ENDPOINT=${S3_ENDPOINT:-} + - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-} + - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-} volumes: - backend_uploads:/app/uploads networks: