963 lines
25 KiB
Go
963 lines
25 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 int, 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 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")
|
|
|
|
// ✅ 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
|
|
}
|
|
|
|
// ✅ 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.Atoi(quantityStr)
|
|
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,
|
|
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"`
|
|
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 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, updated_at = $5
|
|
WHERE id = $6
|
|
`
|
|
|
|
_, err = database.Exec(updateQuery,
|
|
updateData.Name,
|
|
updateData.Category,
|
|
updateData.Description,
|
|
updateData.Stock,
|
|
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
|
|
}
|