chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s

This commit is contained in:
Xor290
2026-08-06 12:06:05 +02:00
parent c034088bee
commit 22a8d5026c
174 changed files with 30315 additions and 16120 deletions
+80 -151
View File
@@ -4,12 +4,12 @@ import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@@ -116,7 +116,6 @@ func validateCategory(database *db.Database, category string) error {
return nil
}
// ✅ 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 {
@@ -130,7 +129,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
}
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])
}
@@ -142,28 +140,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
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
}
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é"})
@@ -172,14 +151,12 @@ func CreateProduct(c *gin.Context) {
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"))
@@ -189,7 +166,6 @@ func CreateProduct(c *gin.Context) {
unit = "u"
}
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -200,7 +176,6 @@ func CreateProduct(c *gin.Context) {
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 {
@@ -219,7 +194,6 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
@@ -231,11 +205,10 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{}
priceIndex := 0
for priceIndex < 100 { // Limite anti-spam
for priceIndex < 100 {
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
@@ -323,7 +296,6 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
@@ -336,13 +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 {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
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)),
@@ -351,10 +323,8 @@ func CreateProduct(c *gin.Context) {
}
totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
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)),
@@ -364,76 +334,50 @@ func CreateProduct(c *gin.Context) {
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)
rollbackFiles(storage, 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)
rollbackFiles(storage, 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, 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)
// ✅ VALIDER LE CHEMIN (protection path traversal)
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
}
// ✅ SAUVEGARDER LE FICHIER
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})
// ✅ CRÉER L'ENTRÉE MÉDIA
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
@@ -480,7 +424,6 @@ func GetProductsByCategory(c *gin.Context) {
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
@@ -499,7 +442,6 @@ func GetProductsByCategory(c *gin.Context) {
return
}
// ✅ Charger les médias
for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media
@@ -533,11 +475,9 @@ func GetProductByID(c *gin.Context) {
})
return
}
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID)
product.Media = media
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product)
@@ -552,7 +492,6 @@ func GetProductByID(c *gin.Context) {
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é"})
@@ -567,7 +506,6 @@ func UpdateProduct(c *gin.Context) {
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
@@ -589,7 +527,6 @@ func UpdateProduct(c *gin.Context) {
return
}
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -733,8 +670,8 @@ func UpdateStock(c *gin.Context) {
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
s3Service := c.MustGet("s3Service").(*services.S3Service)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -753,28 +690,27 @@ func DeleteMedia(c *gin.Context) {
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 {
if err := database.DeleteMedia(mediaID); err != nil {
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
} 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{
"success": true,
"message": "Média supprimé",
@@ -784,7 +720,6 @@ func DeleteMedia(c *gin.Context) {
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"})
@@ -792,19 +727,17 @@ func UploadMedia(c *gin.Context) {
}
role := c.GetString("role")
if role != "admin" && role != "cabine" {
if role != "admin" {
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é"})
@@ -813,7 +746,6 @@ func UploadMedia(c *gin.Context) {
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)"})
@@ -827,7 +759,6 @@ func UploadMedia(c *gin.Context) {
return
}
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
@@ -836,7 +767,6 @@ func UploadMedia(c *gin.Context) {
return
}
// ✅ VÉRIFIER LE TYPE MIME RÉEL
detectedMime, err := validateFileMimeType(file)
if err != nil {
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
@@ -846,7 +776,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
@@ -856,40 +785,32 @@ func UploadMedia(c *gin.Context) {
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, 0750); err != nil {
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
storage := c.MustGet("storage").(services.Storage)
folder := fileType + "s"
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
if err != nil {
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
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 uploadé: %s", mediaURL)
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,
Key: mediaKey,
}
err = database.CreateMedia(&media)
if err != nil {
// Rollback: supprimer le fichier
os.Remove(filePath)
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"})
return
@@ -908,6 +829,28 @@ func UploadMedia(c *gin.Context) {
})
}
func ServeMedia(c *gin.Context) {
s3Service := c.MustGet("s3Service").(*services.S3Service)
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
return
}
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Status(http.StatusOK)
io.Copy(c.Writer, body)
}
func ActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
@@ -952,10 +895,6 @@ func DesActivePrice(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
}
// ============================================
// DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================
func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -976,32 +915,28 @@ func DeleteProduct(c *gin.Context) {
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É
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)
}
}
}
// ✅ 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"})
@@ -1016,17 +951,11 @@ func DeleteProduct(c *gin.Context) {
})
}
// ============================================
// HELPERS
// ============================================
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)
}
}