Files
projet_gestion_commande/backend/gestion/handlers/product.go
T
2026-03-03 23:42:23 +01:00

999 lines
26 KiB
Go

package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
)
// ============================================
// CONFIGURATION & LIMITES
// ============================================
const (
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
MaxFilesPerProduct = 10 // Max 10 fichiers
MaxNameLength = 200
MaxDescLength = 2000
MaxProductsPerUser = 100 // Limite pour éviter spam
)
// ✅ MIME types autorisés (vérification réelle du contenu)
var allowedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"video/mp4": true,
"video/webm": true,
"video/quicktime": true,
}
// ============================================
// MIDDLEWARE D'AUTHORIZATION
// ============================================
func RequireAdminOrCabine() gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Admin ou Cabine requis",
})
c.Abort()
return
}
c.Next()
}
}
// ============================================
// HELPERS DE VALIDATION
// ============================================
func validateProductName(name string) error {
if len(name) == 0 {
return fmt.Errorf("nom requis")
}
if len(name) > MaxNameLength {
return fmt.Errorf("nom trop long (max %d caractères)", MaxNameLength)
}
// Sanitize
if strings.Contains(name, "..") || strings.Contains(name, "/") {
return fmt.Errorf("nom invalide")
}
return nil
}
func validateProductDescription(desc string) error {
if len(desc) == 0 {
return fmt.Errorf("description requise")
}
if len(desc) > MaxDescLength {
return fmt.Errorf("description trop longue (max %d caractères)", MaxDescLength)
}
return nil
}
func validateStock(stock float64) error {
if stock < 0 {
return fmt.Errorf("stock ne peut pas être négatif")
}
if stock > 1000000 {
return fmt.Errorf("stock trop élevé (max 1000000)")
}
return nil
}
func validatePrice(quantity float64, price float64) error {
if quantity <= 0 {
return fmt.Errorf("quantité doit être > 0")
}
if quantity > 10000 {
return fmt.Errorf("quantité trop élevée (max 10000)")
}
if price <= 0 {
return fmt.Errorf("prix doit être > 0")
}
if price > 100000 {
return fmt.Errorf("prix trop élevé (max 100000)")
}
return nil
}
func validateUnit(unit string) error {
validUnits := map[string]bool{
"u": true, // unité
"kg": true, // kilogramme
"g": true, // gramme
"bag": true, // sac
"l": true, // litre
"cl": true, // centilitre
"pcs": true, // pièces
}
if !validUnits[unit] {
return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs")
}
return nil
}
func validateCategory(category string) error {
// Nettoyage
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
validCategories := []string{"weed&hash", "zipette&co", "gros&semi"}
for _, v := range validCategories {
if category == v {
return nil
}
}
return fmt.Errorf("catégorie invalide")
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", err
}
defer file.Close()
mtype, err := mimetype.DetectReader(file)
if err != nil {
return "", err
}
mimeType := mtype.String()
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
if !allowedMimeTypes[mimeType] {
return "", fmt.Errorf("type de fichier non autorisé: %s", mimeType)
}
return mimeType, nil
}
// ✅ PROTECTION CONTRE PATH TRAVERSAL
func sanitizeFilePath(path string) (string, error) {
// Nettoyer le chemin
cleaned := filepath.Clean(path)
// Vérifier qu'il ne contient pas de ".."
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal détecté")
}
// Vérifier qu'il commence par "uploads/"
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
return "", fmt.Errorf("chemin invalide")
}
return cleaned, nil
}
// ============================================
// CREATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
// ✅ PARSER AVEC LIMITE DE TAILLE
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description"))
stockStr := c.PostForm("stock")
unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit")))
if unit == "" {
unit = "u"
}
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateProductDescription(description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
if err := validateCategory(category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUnit(unit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
return
}
if err := validateStock(stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{}
priceIndex := 0
for priceIndex < 100 { // Limite anti-spam
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
quantityStr := c.PostForm(quantityKey)
priceStr := c.PostForm(priceKey)
if quantityStr == "" || priceStr == "" {
break
}
quantity, err := strconv.ParseFloat(quantityStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Prix invalide"})
return
}
if err := validatePrice(quantity, price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prices = append(prices, models.ProductPrice{
Quantity: quantity,
Price: price,
})
priceIndex++
}
if len(prices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
return
}
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
// ✅ CRÉER LE PRODUIT
product := models.Product{
Name: name,
Category: category,
Description: description,
Stock: stock,
Unit: unit,
Prices: prices,
}
err = database.CreateProduct(&product)
if err != nil {
log.Printf("❌ [CreateProduct] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création produit"})
return
}
log.Printf("✅ [CreateProduct] Produit créé: ID=%d", product.ID)
// ✅ TRAITER LES FICHIERS MÉDIAS AVEC SÉCURITÉ
if c.Request.MultipartForm == nil || c.Request.MultipartForm.File == nil {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
return
}
files, exists := c.Request.MultipartForm.File["media"]
if !exists || len(files) == 0 {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
return
}
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Maximum %d fichiers autorisés", MaxFilesPerProduct),
})
return
}
log.Printf("📁 [CreateProduct] %d fichiers à traiter", len(files))
cleanProductName := cleanFileName(product.Name)
uploadedMedia := []models.Media{}
savedFiles := []string{}
var totalSize int64 = 0
for i, fileHeader := range files {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
if fileHeader.Size > MaxFileSize {
rollbackFiles(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)),
})
return
}
totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
if totalSize > MaxTotalUploadSize {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
})
return
}
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
mimeType, err := validateFileMimeType(fileHeader)
if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return
}
// ✅ DÉTERMINER LE TYPE DE MÉDIA
var mediaType string
if strings.HasPrefix(mimeType, "image/") {
mediaType = "image"
} else if strings.HasPrefix(mimeType, "video/") {
mediaType = "video"
} else {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
return
}
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
destFolder := filepath.Join("uploads", mediaType+"s")
if err := os.MkdirAll(destFolder, 0755); 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)
// ✅ VALIDER LE CHEMIN (protection path traversal)
safeFilePath, err := sanitizeFilePath(filePath)
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
}
// ✅ SAUVEGARDER LE FICHIER
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
savedFiles = append(savedFiles, safeFilePath)
// ✅ CRÉER L'ENTRÉE MÉDIA
mediaURL := "/" + filepath.ToSlash(safeFilePath)
media := models.Media{
ProductID: product.ID,
Type: mediaType,
URL: mediaURL,
}
if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
}
uploadedMedia = append(uploadedMedia, media)
log.Printf("✅ [CreateProduct] Média %d créé", media.ID)
}
product.Media = uploadedMedia
log.Printf("🎉 [CreateProduct] SUCCÈS: %d médias enregistrés", len(uploadedMedia))
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
}
// ============================================
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
// ============================================
func GetAllProducts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
products, err := database.GetAllProducts()
if err != nil {
log.Printf("❌ [GetAllProducts] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Erreur récupération produits",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
"count": len(products),
})
}
func GetProductsByCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
products, err := database.GetProductsByCategory(category)
if err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Erreur récupération produits",
})
return
}
// ✅ Charger les médias
for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
"count": len(products),
})
}
func GetProductByID(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "ID invalide",
})
return
}
product, err := database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Produit non trouvé",
})
return
}
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID)
product.Media = media
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": product,
})
}
// ============================================
// UPDATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
var updateData struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Stock float64 `json:"stock"`
Unit string `json:"unit"`
Prices []models.ProductPrice `json:"prices"`
}
if err := c.ShouldBindJSON(&updateData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateProductDescription(updateData.Description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateCategory(updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if updateData.Unit == "" {
updateData.Unit = "u"
}
if err := validateUnit(updateData.Unit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateStock(updateData.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(updateData.Prices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
return
}
for _, price := range updateData.Prices {
if err := validatePrice(price.Quantity, price.Price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
// ✅ UPDATE PRODUIT
updateQuery := `
UPDATE products
SET name = $1, category = $2, description = $3, stock = $4, unit = $5, updated_at = $6
WHERE id = $7
`
_, err = database.Exec(updateQuery,
updateData.Name,
updateData.Category,
updateData.Description,
updateData.Stock,
updateData.Unit,
time.Now(),
id,
)
if err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
// ✅ UPDATE PRIX
database.Exec(`DELETE FROM product_prices WHERE product_id = $1`, id)
for _, price := range updateData.Prices {
_, err := database.Exec(`
INSERT INTO product_prices (product_id, quantity, price)
VALUES ($1, $2, $3)
`, id, price.Quantity, price.Price)
if err != nil {
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
}
}
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id)
updatedProduct.Media = media
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"product": updatedProduct,
})
}
// ============================================
// DELETE MEDIA - VERSION SÉCURISÉE
// ============================================
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
mediaID, err := strconv.Atoi(c.Param("media_id"))
if err != nil || mediaID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
media, err := database.GetMediaByID(mediaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
filePath := strings.TrimPrefix(media.URL, "/")
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
}
// ✅ SUPPRIMER DE LA DB
err = database.DeleteMedia(mediaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Média supprimé",
})
}
// handlers/product_handlers_SECURED.go
func UploadMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
productID, err := strconv.Atoi(c.Param("id"))
if err != nil || productID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
productName, err := database.GetProductNameByID(productID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
fileType := c.PostForm("type")
if fileType != "image" && fileType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
return
}
file, err := c.FormFile("file")
if err != nil {
log.Printf("❌ [UploadMedia] Erreur récupération fichier: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichier manquant"})
return
}
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier trop volumineux (max %dMB)", MaxFileSize/(1024*1024)),
})
return
}
// ✅ VÉRIFIER LE TYPE MIME RÉEL
fileHeader, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture fichier"})
return
}
defer fileHeader.Close()
buffer := make([]byte, 512)
_, err = fileHeader.Read(buffer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture fichier"})
return
}
mimeType := http.DetectContentType(buffer)
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", mimeType)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(mimeType, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
}
if fileType == "video" && !strings.HasPrefix(mimeType, "video/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une vidéo valide"})
return
}
// ✅ GÉNÉRER UN NOM UNIQUE
cleanProductName := cleanFileName(productName)
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
// ✅ CRÉER LE DOSSIER
destFolder := filepath.Join("uploads", fileType+"s")
if err := os.MkdirAll(destFolder, 0755); err != nil {
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
return
}
// ✅ SAUVEGARDER LE FICHIER
filePath := filepath.Join(destFolder, uniqueFileName)
if err := c.SaveUploadedFile(file, filePath); err != nil {
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
// ✅ CRÉER L'ENTRÉE EN BASE
mediaURL := "/" + filepath.ToSlash(filePath)
media := models.Media{
ProductID: productID,
Type: fileType,
URL: mediaURL,
}
err = database.CreateMedia(&media)
if err != nil {
// Rollback: supprimer le fichier
os.Remove(filePath)
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
}
log.Printf("✅ [UploadMedia] Média #%d créé pour produit #%d", media.ID, productID)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Média uploadé avec succès",
"media": media,
"uploaded_by": gin.H{
"username": username,
"role": role,
},
})
}
// ============================================
// DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================
func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
mediaList, err := database.GetMediaByProductID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return
}
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
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)
}
}
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
database.DeleteMediaByProductID(id)
// ✅ SUPPRIMER LE PRODUIT
err = database.DeleteProduct(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
return
}
log.Printf("✅ [DeleteProduct] Produit #%d supprimé", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit supprimé",
})
}
// ============================================
// HELPERS
// ============================================
func rollbackFiles(files []string) {
for _, file := range files {
safeFilePath, err := sanitizeFilePath(file)
if err != nil {
continue
}
os.Remove(safeFilePath)
}
}
func cleanFileName(name string) string {
replacements := map[string]string{
"/": "-", "\\": "-", ":": "-",
"*": "-", "?": "-", "\"": "-",
"<": "-", ">": "-", "|": "-",
" ": "_",
}
result := name
for old, new := range replacements {
result = strings.ReplaceAll(result, old, new)
}
// Limiter la longueur
if len(result) > 50 {
result = result[:50]
}
return result
}