chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -0,0 +1,959 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
|
||||
// Lire les premiers 512 bytes pour détecter le type MIME
|
||||
buffer := make([]byte, 512)
|
||||
_, err = file.Read(buffer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(buffer)
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user