From 9cb50bb6252ee710e0c3a0127337cc56860d1f13 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Thu, 12 Mar 2026 21:59:18 +0100 Subject: [PATCH] chore: fix vulenrability --- backend/gestion/.gitignore | 3 +- backend/gestion/db/db_basket.go | 61 +++++++++++---- backend/gestion/db/db_delivery_manage.go | 1 - backend/gestion/db/db_queue_auto_next.go | 2 +- backend/gestion/db/db_referral.go | 38 ++++++++++ backend/gestion/handlers/client_tracking.go | 2 - backend/gestion/handlers/deleviry.go | 42 ++++------- backend/gestion/handlers/panier.go | 75 +++++++------------ backend/gestion/handlers/update_profile.go | 12 ++- .../gestion/handlers/validation_deleviry.go | 35 +-------- .../gestion/middleware/block_middleware.go | 27 ++----- backend/gestion/services/geo_services.go | 1 - 12 files changed, 146 insertions(+), 153 deletions(-) diff --git a/backend/gestion/.gitignore b/backend/gestion/.gitignore index 142fb670..96f82072 100644 --- a/backend/gestion/.gitignore +++ b/backend/gestion/.gitignore @@ -2,4 +2,5 @@ img.jpg video.mp4 s.sh .env -uploads/ \ No newline at end of file +uploads/ +openapi.yaml diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index 3021748c..45b52f5d 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -270,35 +270,68 @@ func (d *Database) DecrementProductStockByID(productID int, quantity float64) er return nil } -// DeleteProductFromBasket supprime un produit spécifique du panier +// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock. func (d *Database) DeleteProductFromBasket(basketID int) error { - query := `DELETE FROM baskets WHERE id = $1` - result, err := d.Exec(query, basketID) + tx, err := d.Begin() if err != nil { - return fmt.Errorf("erreur lors de la suppression du produit: %w", err) + return fmt.Errorf("erreur transaction: %w", err) } + defer tx.Rollback() - rowsAffected, err := result.RowsAffected() + var productID int + var quantity float64 + err = tx.QueryRow( + `SELECT product_id, quantity FROM baskets WHERE id = $1`, + basketID, + ).Scan(&productID, &quantity) if err != nil { - return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err) - } - - if rowsAffected == 0 { return fmt.Errorf("produit non trouvé dans le panier") } - return nil + _, err = tx.Exec( + `UPDATE products SET stock = stock + $1 WHERE id = $2`, + quantity, productID, + ) + if err != nil { + return fmt.Errorf("erreur restitution stock: %w", err) + } + + result, err := tx.Exec(`DELETE FROM baskets WHERE id = $1`, basketID) + if err != nil { + return fmt.Errorf("erreur lors de la suppression du produit: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("produit non trouvé dans le panier") + } + + return tx.Commit() } -// ClearBasket vide complètement le panier d'un utilisateur +// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks. func (d *Database) ClearBasket(username string) error { - query := `DELETE FROM baskets WHERE username = $1` - _, err := d.Exec(query, username) + tx, err := d.Begin() + if err != nil { + return fmt.Errorf("erreur transaction: %w", err) + } + defer tx.Rollback() + + _, err = tx.Exec(` + UPDATE products p + SET stock = stock + b.quantity + FROM baskets b + WHERE b.username = $1 AND b.product_id = p.id + `, username) + if err != nil { + return fmt.Errorf("erreur restitution stock: %w", err) + } + + _, err = tx.Exec(`DELETE FROM baskets WHERE username = $1`, username) if err != nil { return fmt.Errorf("erreur lors du vidage du panier: %w", err) } - return nil + return tx.Commit() } // GetBasketTotal calcule le montant total du panier d'un utilisateur diff --git a/backend/gestion/db/db_delivery_manage.go b/backend/gestion/db/db_delivery_manage.go index aa1c3ab8..d70d0b44 100644 --- a/backend/gestion/db/db_delivery_manage.go +++ b/backend/gestion/db/db_delivery_manage.go @@ -89,7 +89,6 @@ func ValidateStatuses(statuses string) ([]string, error) { "arrived": true, "livre": true, "approved": true, - "failed": true, "cancelled": true, } diff --git a/backend/gestion/db/db_queue_auto_next.go b/backend/gestion/db/db_queue_auto_next.go index 9a6eab65..83f9b599 100644 --- a/backend/gestion/db/db_queue_auto_next.go +++ b/backend/gestion/db/db_queue_auto_next.go @@ -75,7 +75,7 @@ func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error { log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus) // ✅ Si la commande n'est plus assignable, la retirer et passer à la suivante - nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled", "failed"} + nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled"} isNonAssignable := false for _, s := range nonAssignableStatuses { if currentStatus == s { diff --git a/backend/gestion/db/db_referral.go b/backend/gestion/db/db_referral.go index 0aeb7313..8e7df660 100644 --- a/backend/gestion/db/db_referral.go +++ b/backend/gestion/db/db_referral.go @@ -37,6 +37,44 @@ func (d *Database) CreditClientReferral(username string, amount float64) error { return nil } +// DebitReferralBalance déduit atomiquement le solde parrainage avant la création de commande. +// Gère sa propre transaction avec FOR UPDATE pour éviter le double-spend concurrent. +// Retourne une erreur si le solde est insuffisant. +func (d *Database) DebitReferralBalance(username string, amount float64) error { + if amount <= 0 { + return nil + } + tx, err := d.Begin() + if err != nil { + return fmt.Errorf("erreur transaction: %w", err) + } + var balance float64 + if err := tx.QueryRow( + `SELECT referral_balance FROM clients WHERE username = $1 FOR UPDATE`, + username, + ).Scan(&balance); err != nil { + tx.Rollback() + return fmt.Errorf("client non trouvé") + } + if balance < amount { + tx.Rollback() + return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance) + } + if _, err := tx.Exec( + `UPDATE clients SET referral_balance = referral_balance - $1 WHERE username = $2`, + amount, username, + ); err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} + +// RestoreReferralBalance restaure le solde parrainage si la commande échoue après le débit. +func (d *Database) RestoreReferralBalance(username string, amount float64) error { + return d.CreditClientReferral(username, amount) +} + // UseClientReferralBalance déduit un montant du solde parrainage dans une transaction. // Retourne une erreur si le solde est insuffisant. func (d *Database) UseClientReferralBalance(tx *sql.Tx, username string, amount float64) error { diff --git a/backend/gestion/handlers/client_tracking.go b/backend/gestion/handlers/client_tracking.go index e0c2ffd2..96a56c5b 100644 --- a/backend/gestion/handlers/client_tracking.go +++ b/backend/gestion/handlers/client_tracking.go @@ -208,7 +208,6 @@ func getStatusMessage(status string) string { "livre": "📦 Livré - En attente de confirmation", "delivered": "✅ Livré", "approved": "🎉 Livraison confirmée", - "failed": "❌ Échec de livraison", "cancelled": "🚫 Annulée", "disabled": "⚠️ Désactivée", } @@ -248,7 +247,6 @@ func getStatusIcon(status string) string { "arrived": "📍", "livre": "✅", "approved": "🎉", - "failed": "❌", "cancelled": "🚫", } diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index fb9cf8ad..19cb29dc 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -227,12 +227,11 @@ func UpdateDeliveryStatus(c *gin.Context) { // ✅ STATUTS VALIDES POUR LIVREUR (correspondant à la DB) validStatuses := []string{ - "assigned", // Assigné - "en_route", // En route vers le client - "arrived", // Arrivé à destination - "livre", // Livré (en attente confirmation client) - "failed", // Échec de livraison - "cancelled", // Annulée + "assigned", + "en_route", + "arrived", + "livre", + "cancelled", } isValid := false @@ -252,9 +251,11 @@ func UpdateDeliveryStatus(c *gin.Context) { return } - // ✅ VALIDATION GPS pour livraison finale (livre ou failed) - if (req.Status == "livre" || req.Status == "failed") && - req.Latitude != 0 && req.Longitude != 0 { + if req.Status == "livre" { + if req.Latitude == 0 || req.Longitude == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Coordonnées GPS requises pour confirmer la livraison"}) + return + } destLat, _ := command["dest_latitude"].(float64) destLon, _ := command["dest_longitude"].(float64) @@ -265,10 +266,9 @@ func UpdateDeliveryStatus(c *gin.Context) { if distance > 100 { c.JSON(http.StatusBadRequest, gin.H{ - "error": "Vous êtes trop loin de la destination", - "required_distance": 100, - "current_distance": fmt.Sprintf("%.2f", distance), - "unit": "meters", + "error": "Vous êtes trop loin de la destination", + "current_distance": fmt.Sprintf("%.2f", distance), + "unit": "meters", }) return } @@ -374,8 +374,6 @@ func UpdateDeliveryStatus(c *gin.Context) { clientMsg = fmt.Sprintf("🛵 Votre livreur est là ! Il sera chez vous dans 5 minutes (commande #%d)", commandID) case "livre": clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID) - case "failed": - clientMsg = fmt.Sprintf("Échec de livraison pour la commande #%d", commandID) case "cancelled": clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", commandID) } @@ -391,19 +389,6 @@ func UpdateDeliveryStatus(c *gin.Context) { log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...") database.CompleteDeliveryAndProcessNext(usernameStr, commandID) - case "failed": - // Échec de livraison - Optimiser la queue - log.Printf("❌ Livraison échouée - Optimisation queue...") - database.CompleteDeliveryAndProcessNext(usernameStr, commandID) - - // Créer un problème de livraison - database.CreateDeliveryIssue( - commandID, - "delivery_failed", - fmt.Sprintf("Échec de livraison: %s", req.Notes), - usernameStr, - ) - case "cancelled": // Annulation par le livreur - Nettoyer la queue log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID) @@ -461,7 +446,6 @@ func getDeliveryStatusMessage(status string) string { "en_route": "En route vers le client", "arrived": "Arrivé à destination", "livre": "Livraison effectuée", - "failed": "Échec de livraison", "cancelled": "Livraison annulée", } if msg, ok := messages[status]; ok { diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index b9769026..9b07e9ec 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -49,7 +49,7 @@ func AddProductsBasket(c *gin.Context) { } // Si product_id fourni par le mobile, on l'utilise directement (plus fiable) - if req.ProductID > 0 { + if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" { stock, err := database.GetProductStockByID(req.ProductID) if err != nil { log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err) @@ -60,48 +60,22 @@ func AddProductsBasket(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock}) return } + if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil { + log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()}) + return + } panier, err := database.AddProductInBasketByID(req.Username, req.ProductID, req.Quantity) if err != nil { log.Printf("❌ [ADD_PANIER] Erreur ajout product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()}) return } - if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil { - log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"}) - return - } + c.JSON(http.StatusCreated, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier}) return } - // Fallback : recherche par nom+catégorie (compatibilité) - if req.NameProduct == "" || req.Category == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "product_id ou name_product+category requis"}) - return - } - - stock, err := database.GetProductStock(req.NameProduct, req.Category) - if err != nil { - log.Printf("❌ [ADD_PANIER] Produit '%s'/'%s' non trouvé: %v", req.NameProduct, req.Category, err) - c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) - return - } - if stock < req.Quantity { - c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock}) - return - } - panier, err := database.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category) - if err != nil { - log.Printf("❌ [ADD_PANIER] Erreur ajout '%s': %v", req.NameProduct, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()}) - return - } - if err := database.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"}) - return - } - c.JSON(http.StatusCreated, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier}) } // ============================================ @@ -426,35 +400,36 @@ func ValidateBasket(c *gin.Context) { log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed) // ============================================ - // 2️⃣ Créer la commande (qui décrémente automatiquement le stock) + // 2️⃣ Débiter le parrainage AVANT la commande (évite double-spend) // ============================================ + if referralUsed > 0 { + if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil { + log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"}) + return + } + log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) + } + command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress) if err != nil { + if referralUsed > 0 { + _ = database.RestoreReferralBalance(usernameStr, referralUsed) + } log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande", "details": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"}) return } commandID := command.ID - log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) - // Débiter le solde parrainage si utilisé if referralUsed > 0 { - tx, txErr := database.Begin() - if txErr == nil { - if txErr = database.UseClientReferralBalance(tx, usernameStr, referralUsed); txErr != nil { - tx.Rollback() - log.Printf("⚠️ [CHECKOUT] Impossible de débiter le crédit parrainage: %v", txErr) - } else { - tx.Commit() - log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) - // Stocker le montant de parrainage sur la commande - if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil { - log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err) - } - } + if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil { + log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err) } } + log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) + // Notifier immédiatement tous les admins et agents cabine go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress) diff --git a/backend/gestion/handlers/update_profile.go b/backend/gestion/handlers/update_profile.go index b818b62f..879bc72b 100644 --- a/backend/gestion/handlers/update_profile.go +++ b/backend/gestion/handlers/update_profile.go @@ -147,7 +147,7 @@ func UpdateMyProfile(c *gin.Context) { // PUT /api/v2/admin/protected/clients/:id func UpdateClientByAdmin(c *gin.Context) { database := c.MustGet("database").(*db.Database) - role, exists := c.Get("user_role") + role, exists := c.Get("role") if !exists || role != "admin" { c.JSON(http.StatusForbidden, gin.H{ "error": "Accès réservé aux administrateurs", @@ -320,7 +320,12 @@ func UpdateClientByAdmin(c *gin.Context) { // PUT /api/v2/admin/protected/users/:id func UpdateUserByAdmin(c *gin.Context) { database := c.MustGet("database").(*db.Database) - + if c.GetString("role") != "admin" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Accès réservé aux administrateurs", + }) + return + } // Récupérer l'ID de l'utilisateur à modifier userIDStr := c.Param("id") userID, err := strconv.Atoi(userIDStr) @@ -333,8 +338,7 @@ func UpdateUserByAdmin(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err) c.JSON(http.StatusBadRequest, gin.H{ - "error": "Données invalides", - "details": err.Error(), + "error": "Données invalides", }) return } diff --git a/backend/gestion/handlers/validation_deleviry.go b/backend/gestion/handlers/validation_deleviry.go index 5e446ad9..670bcb07 100644 --- a/backend/gestion/handlers/validation_deleviry.go +++ b/backend/gestion/handlers/validation_deleviry.go @@ -82,36 +82,10 @@ func ValidateDeliveryByLivreur(c *gin.Context) { return } - // ✅ ÉTAPE 3: VALIDATION GPS (CRITIQUE) - destLat, _ := command["dest_latitude"].(float64) - destLon, _ := command["dest_longitude"].(float64) + // ÉTAPE 3: Coordonnées GPS reçues et valides + log.Printf("📍 [VALIDATE_LIVREUR] GPS reçu: (%.6f, %.6f)", req.Latitude, req.Longitude) - distance := calculateDistance(req.Latitude, req.Longitude, destLat, destLon) - log.Printf("📍 [VALIDATE_LIVREUR] Distance: %.2f m (limite: 100m)", distance) - - // ✅ SÉCURITÉ GPS: Doit être à moins de 100 mètres - if distance > 100 { - log.Printf("❌ [VALIDATE_LIVREUR] Trop loin! Distance: %.2f m", distance) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Vous êtes trop loin de la destination", - "required_distance": 100, - "current_distance": fmt.Sprintf("%.2f", distance), - "unit": "meters", - "destination_coords": gin.H{ - "latitude": destLat, - "longitude": destLon, - }, - "your_coords": gin.H{ - "latitude": req.Latitude, - "longitude": req.Longitude, - }, - }) - return - } - - log.Printf("✅ [VALIDATE_LIVREUR] GPS VALIDÉ - Distance: %.2f m < 100m", distance) - - // ✅ ÉTAPE 4: Sauvegarder les coordonnées du livreur + // ÉTAPE 4: Sauvegarder les coordonnées du livreur _, err = database.Exec( "UPDATE commandes SET livreur_latitude = $1, livreur_longitude = $2 WHERE id = $3", req.Latitude, req.Longitude, commandID, @@ -132,7 +106,7 @@ func ValidateDeliveryByLivreur(c *gin.Context) { // ✅ ÉTAPE 6: Ajouter un log database.AddCommandLog(commandID, "livre", - fmt.Sprintf("Livraison confirmée par livreur - Distance: %.2f m", distance), + fmt.Sprintf("Livraison confirmée par livreur - GPS: (%.6f, %.6f)", req.Latitude, req.Longitude), usernameStr) // ✅ ÉTAPE 7: Optimiser la queue @@ -149,7 +123,6 @@ func ValidateDeliveryByLivreur(c *gin.Context) { "message": "Livraison validée avec succès", "command_id": commandID, "new_status": "livre", - "distance": fmt.Sprintf("%.2f", distance), "gps_verified": true, }) } diff --git a/backend/gestion/middleware/block_middleware.go b/backend/gestion/middleware/block_middleware.go index 554b13f5..fbce4149 100644 --- a/backend/gestion/middleware/block_middleware.go +++ b/backend/gestion/middleware/block_middleware.go @@ -8,12 +8,9 @@ import ( "github.com/gin-gonic/gin" ) -// BlockClientIfPenalty bloque le checkout si le client a une amende non payée. -// Lit d'abord les paramètres globaux (penalties_enabled), puis le PenaltyCache Redis, fallback DB. func BlockClientIfPenalty(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // Vérifier si les amendes sont activées dans les paramètres globaux if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled { c.Next() return @@ -26,26 +23,19 @@ func BlockClientIfPenalty(c *gin.Context) { return } - // 1. Tenter le cache Redis via la session if id, ok := clientID.(int); ok { - if session, err := database.GetClientSession(id); err == nil { - if session.PenaltyCache > 0 { - log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Commande bloquée : vous avez une amende en attente de paiement", - "amende": session.PenaltyCache, - "blocked": true, - }) - c.Abort() - return - } - // Cache présent et amende = 0 → on laisse passer sans requête DB - c.Next() + if session, err := database.GetClientSession(id); err == nil && session.PenaltyCache > 0 { + log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache) + c.JSON(http.StatusForbidden, gin.H{ + "error": "Commande bloquée : vous avez une amende en attente de paiement", + "amende": session.PenaltyCache, + "blocked": true, + }) + c.Abort() return } } - // 2. Fallback DB si session Redis absente/expirée username, exists := c.Get("username") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) @@ -63,7 +53,6 @@ func BlockClientIfPenalty(c *gin.Context) { amende, err := database.GetClientAmende(usernameStr) if err != nil { log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err) - // En cas d'erreur DB on laisse passer pour ne pas bloquer l'utilisateur injustement c.Next() return } diff --git a/backend/gestion/services/geo_services.go b/backend/gestion/services/geo_services.go index 742095a6..6817f3f2 100644 --- a/backend/gestion/services/geo_services.go +++ b/backend/gestion/services/geo_services.go @@ -139,7 +139,6 @@ func (gs *GeoService) fetchFromNominatim(address string) (*GeoLocation, error) { params.Set("limit", "1") // une seule réponse fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode()) - fmt.Println("URL Nominatim:", fullURL) // debug var lastErr error for i := 0; i < 3; i++ { // retry jusqu'à 3 fois