diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 72b742a2b..16ab16898 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" // MOD-P2-006: Pour pprof "os" + "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -52,6 +54,46 @@ func NewAPIRouter(db *database.Database, cfg *config.Config) *APIRouter { } } +// getUploadConfigWithEnv charge la configuration d'upload depuis l'environnement +// Cette fonction garantit que ENABLE_CLAMAV et CLAMAV_REQUIRED sont correctement appliqués +func getUploadConfigWithEnv() *services.UploadConfig { + uploadConfig := services.DefaultUploadConfig() + + // Lire ENABLE_CLAMAV depuis l'environnement (défaut: true pour sécurité en production) + envValue := os.Getenv("ENABLE_CLAMAV") + fmt.Printf("🔍 [ROUTER] ENABLE_CLAMAV depuis env: '%s'\n", envValue) + clamAVEnabled := getEnvBool("ENABLE_CLAMAV", true) + fmt.Printf("🔍 [ROUTER] ENABLE_CLAMAV parsé: %v\n", clamAVEnabled) + uploadConfig.ClamAVEnabled = clamAVEnabled + + // Lire CLAMAV_REQUIRED depuis l'environnement (défaut: true pour sécurité) + clamAVRequired := getEnvBool("CLAMAV_REQUIRED", true) + uploadConfig.ClamAVRequired = clamAVRequired + + fmt.Printf("🔧 [ROUTER] Configuration finale - ClamAVEnabled=%v, ClamAVRequired=%v\n", + uploadConfig.ClamAVEnabled, uploadConfig.ClamAVRequired) + + return uploadConfig +} + +// getEnvBool récupère une variable d'environnement booléenne avec une valeur par défaut +func getEnvBool(key string, defaultValue bool) bool { + value := os.Getenv(key) + if value == "" { + fmt.Printf("🔍 [ROUTER] Variable %s non définie, utilisation défaut: %v\n", key, defaultValue) + return defaultValue + } + // Nettoyer la valeur (trim spaces) + value = strings.TrimSpace(value) + fmt.Printf("🔍 [ROUTER] Variable %s='%s' (trimmed)\n", key, value) + if boolValue, err := strconv.ParseBool(value); err == nil { + fmt.Printf("🔍 [ROUTER] Variable %s parsée: %v\n", key, boolValue) + return boolValue + } + fmt.Printf("⚠️ [ROUTER] Erreur parsing %s='%s', utilisation défaut: %v\n", key, value, defaultValue) + return defaultValue +} + // Setup configure toutes les routes de l'API func (r *APIRouter) Setup(router *gin.Engine) error { r.engine = router @@ -350,7 +392,10 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) { trackHandler.SetPermissionService(r.config.PermissionService) } // MOD-P1-001: Set upload validator for ClamAV scan before persistence - uploadConfig := services.DefaultUploadConfig() + // CORRECTION: Charger la config depuis l'environnement pour respecter ENABLE_CLAMAV + uploadConfig := getUploadConfigWithEnv() + fmt.Printf("🔧 [ROUTER] Configuration upload chargée - ClamAVEnabled=%v, ClamAVRequired=%v\n", + uploadConfig.ClamAVEnabled, uploadConfig.ClamAVRequired) uploadValidator, err := services.NewUploadValidator(uploadConfig, r.logger) if err != nil { r.logger.Warn("Upload validator created with ClamAV unavailable - uploads will be rejected", zap.Error(err)) @@ -613,7 +658,8 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) { // Upload info endpoints (public, already in /api/v1) // MOD-P1-001-REFINEMENT: Permettre démarrage même si ClamAV down if r.db != nil && r.db.GormDB != nil { - uploadConfig := services.DefaultUploadConfig() + // CORRECTION: Charger la config depuis l'environnement pour respecter ENABLE_CLAMAV + uploadConfig := getUploadConfigWithEnv() uploadValidator, err := services.NewUploadValidator(uploadConfig, r.logger) if err != nil { r.logger.Warn("Upload validator created with ClamAV unavailable - uploads will be rejected", zap.Error(err)) @@ -643,7 +689,8 @@ func (r *APIRouter) setupCoreProtectedRoutes(v1 *gin.RouterGroup) { // Services nécessaires sessionService := services.NewSessionService(r.db, r.logger) - uploadConfig := services.DefaultUploadConfig() + // CORRECTION: Charger la config depuis l'environnement pour respecter ENABLE_CLAMAV + uploadConfig := getUploadConfigWithEnv() // MOD-P1-001-REFINEMENT: Permettre démarrage même si ClamAV down uploadValidator, err := services.NewUploadValidator(uploadConfig, r.logger) if err != nil { diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index 27e2f7789..d35b5d53c 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -323,6 +323,13 @@ func (c *Config) initServices() error { // Validateur d'upload uploadConfig := services.DefaultUploadConfig() + // Lire ENABLE_CLAMAV depuis l'environnement (défaut: true pour sécurité en production) + // En développement, peut être désactivé si ClamAV n'est pas disponible + clamAVEnabled := getEnvBool("ENABLE_CLAMAV", true) + uploadConfig.ClamAVEnabled = clamAVEnabled + if !clamAVEnabled { + c.Logger.Warn("ENABLE_CLAMAV=false - ClamAV virus scanning is disabled. This should only be used in development environments.") + } // MOD-P1-002: Lire CLAMAV_REQUIRED depuis l'environnement (défaut: true pour sécurité) clamAVRequired := getEnvBool("CLAMAV_REQUIRED", true) uploadConfig.ClamAVRequired = clamAVRequired diff --git a/veza-backend-api/internal/core/track/handler.go b/veza-backend-api/internal/core/track/handler.go index 39fc85c2b..66b2a53f6 100644 --- a/veza-backend-api/internal/core/track/handler.go +++ b/veza-backend-api/internal/core/track/handler.go @@ -152,18 +152,24 @@ func (h *TrackHandler) respondWithError(c *gin.Context, httpStatus int, message // @Failure 500 {object} response.APIResponse "Internal Error" // @Router /tracks [post] func (h *TrackHandler) UploadTrack(c *gin.Context) { + fmt.Printf("📥 [UPLOAD] Réception requête upload track\n") + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { + fmt.Printf("❌ [UPLOAD] Utilisateur non authentifié\n") return // Erreur déjà envoyée par getUserID } + fmt.Printf("👤 [UPLOAD] User ID: %s\n", userID.String()) fileHeader, err := c.FormFile("file") if err != nil { + fmt.Printf("❌ [UPLOAD] Erreur récupération fichier: %v\n", err) // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest h.respondWithError(c, http.StatusBadRequest, "no file provided") return } + fmt.Printf("📂 [UPLOAD] Fichier reçu: %s (taille: %d bytes)\n", fileHeader.Filename, fileHeader.Size) // MOD-P1-001: Scanner le fichier avec ClamAV AVANT toute persistance if h.uploadValidator != nil { @@ -221,10 +227,12 @@ func (h *TrackHandler) UploadTrack(c *gin.Context) { // MOD-P1-001: Le scan ClamAV a été fait ci-dessus, maintenant on peut persister // MOD-P2-008: UploadTrack crée le Track immédiatement et lance la copie en goroutine // MOD-P1-004: Ajouter timeout context pour opération DB critique (upload track) + fmt.Printf("💾 [UPLOAD] Début sauvegarde track...\n") ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Upload peut prendre du temps defer cancel() track, err := h.trackService.UploadTrack(ctx, userID, fileHeader) if err != nil { + fmt.Printf("❌ [UPLOAD] Erreur sauvegarde track: %v\n", err) // Mapper les erreurs vers des messages utilisateur spécifiques errorMessage := h.mapTrackError(err) statusCode := h.getErrorStatusCode(err) diff --git a/veza-backend-api/internal/core/track/service.go b/veza-backend-api/internal/core/track/service.go index 0b6ea1a71..9ff752500 100644 --- a/veza-backend-api/internal/core/track/service.go +++ b/veza-backend-api/internal/core/track/service.go @@ -158,15 +158,19 @@ func (s *TrackService) UploadTrack(ctx context.Context, userID uuid.UUID, fileHe } // Créer le répertoire d'upload s'il n'existe pas + fmt.Printf("📁 [UPLOAD] Vérification dossier upload: %s\n", s.uploadDir) if err := os.MkdirAll(s.uploadDir, 0755); err != nil { + fmt.Printf("❌ [UPLOAD] Erreur création dossier: %v\n", err) return nil, fmt.Errorf("%w: failed to create upload directory: %w", ErrStorageError, err) } + fmt.Printf("✅ [UPLOAD] Dossier upload créé/vérifié: %s\n", s.uploadDir) // Générer un nom de fichier unique timestamp := uuid.New() ext := filepath.Ext(fileHeader.Filename) filename := fmt.Sprintf("%d_%d%s", userID, timestamp, ext) filePath := filepath.Join(s.uploadDir, filename) + fmt.Printf("💾 [UPLOAD] Chemin fichier de destination: %s\n", filePath) // Déterminer le format depuis l'extension format := strings.TrimPrefix(strings.ToUpper(ext), ".") @@ -194,11 +198,14 @@ func (s *TrackService) UploadTrack(ctx context.Context, userID uuid.UUID, fileHe } if err := s.db.WithContext(ctx).Create(track).Error; err != nil { + fmt.Printf("❌ [UPLOAD] Erreur création enregistrement track: %v\n", err) return nil, fmt.Errorf("failed to create track record: %w", err) } + fmt.Printf("✅ [UPLOAD] Enregistrement track créé en DB (ID: %s)\n", track.ID.String()) // MOD-P2-008: Lancer la copie fichier en goroutine avec suivi (context + cancellation) // La goroutine mettra à jour le Status quand terminé + fmt.Printf("🚀 [UPLOAD] Lancement copie fichier en asynchrone...\n") go s.copyFileAsync(ctx, track.ID, fileHeader, filePath, userID) // MOD-P2-003: Enregistrer la métrique business @@ -222,30 +229,39 @@ func (s *TrackService) copyFileAsync(ctx context.Context, trackID uuid.UUID, fil defer cancel() // Ouvrir le fichier source + fmt.Printf("📂 [UPLOAD ASYNC] Ouverture fichier source...\n") src, err := fileHeader.Open() if err != nil { + fmt.Printf("❌ [UPLOAD ASYNC] Erreur ouverture fichier source: %v\n", err) s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Failed to open uploaded file: %v", err)) s.cleanupFailedUpload(filePath, trackID, "failed to open source file") return } defer src.Close() + fmt.Printf("✅ [UPLOAD ASYNC] Fichier source ouvert\n") // Créer le fichier de destination + fmt.Printf("💾 [UPLOAD ASYNC] Création fichier destination: %s\n", filePath) dst, err := os.Create(filePath) if err != nil { + fmt.Printf("❌ [UPLOAD ASYNC] Erreur création fichier destination: %v\n", err) s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Failed to create destination file: %v", err)) s.cleanupFailedUpload(filePath, trackID, "failed to create destination file") return } defer dst.Close() + fmt.Printf("✅ [UPLOAD ASYNC] Fichier destination créé\n") // Copier le fichier avec gestion d'erreurs + fmt.Printf("📋 [UPLOAD ASYNC] Début copie fichier...\n") bytesWritten, err := io.Copy(dst, src) if err != nil { + fmt.Printf("❌ [UPLOAD ASYNC] Erreur copie fichier: %v\n", err) s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Failed to save file: %v", err)) s.cleanupFailedUpload(filePath, trackID, fmt.Sprintf("copy failed: %v", err)) return } + fmt.Printf("✅ [UPLOAD ASYNC] Fichier copié avec succès (%d bytes écrits)\n", bytesWritten) // Vérifier si le contexte a été annulé select { diff --git a/veza-backend-api/internal/middleware/cors.go b/veza-backend-api/internal/middleware/cors.go index 7ca8e5bb0..eb0d817e3 100644 --- a/veza-backend-api/internal/middleware/cors.go +++ b/veza-backend-api/internal/middleware/cors.go @@ -17,8 +17,9 @@ func CORS(allowedOrigins []string) gin.HandlerFunc { } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With") c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "86400") // Cache preflight pour 24h if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) diff --git a/veza-backend-api/internal/services/refresh_token_service.go b/veza-backend-api/internal/services/refresh_token_service.go index 02863b29b..3e0dccffd 100644 --- a/veza-backend-api/internal/services/refresh_token_service.go +++ b/veza-backend-api/internal/services/refresh_token_service.go @@ -6,9 +6,10 @@ import ( "errors" "time" + "veza-backend-api/internal/models" + "github.com/google/uuid" "gorm.io/gorm" - "veza-backend-api/internal/models" ) // RefreshTokenService gère le stockage et la validation des refresh tokens @@ -27,7 +28,9 @@ func NewRefreshTokenService(db *gorm.DB) *RefreshTokenService { // MIGRATION UUID: userID migré vers uuid.UUID func (s *RefreshTokenService) Store(userID uuid.UUID, token string, ttl time.Duration) error { tokenHash := s.hashToken(token) - expiresAt := time.Now().Add(ttl) + // Ajouter un buffer de 1 seconde pour garantir que expires_at > created_at + // Cela évite les problèmes de précision des timestamps entre Go et PostgreSQL + expiresAt := time.Now().Add(ttl).Add(1 * time.Second) refreshToken := &models.RefreshToken{ UserID: userID, @@ -76,10 +79,12 @@ func (s *RefreshTokenService) Rotate(userID uuid.UUID, oldToken, newToken string // Stocker le nouveau newTokenHash := s.hashToken(newToken) + // Ajouter un buffer de 1 seconde pour garantir que expires_at > created_at + // Cela évite les problèmes de précision des timestamps entre Go et PostgreSQL refreshToken := &models.RefreshToken{ UserID: userID, TokenHash: newTokenHash, - ExpiresAt: time.Now().Add(ttl), + ExpiresAt: time.Now().Add(ttl).Add(1 * time.Second), } return tx.Create(refreshToken).Error diff --git a/veza-backend-api/internal/services/upload_validator.go b/veza-backend-api/internal/services/upload_validator.go index 4429b3d1a..2afbae2ea 100644 --- a/veza-backend-api/internal/services/upload_validator.go +++ b/veza-backend-api/internal/services/upload_validator.go @@ -88,35 +88,82 @@ func DefaultUploadConfig() *UploadConfig { // MOD-P1-001-REFINEMENT: Fail-secure localisé - serveur démarre même si ClamAV down, // mais uploads seront rejetés lors de la validation func NewUploadValidator(config *UploadConfig, logger *zap.Logger) (*UploadValidator, error) { + fmt.Printf("🔧 [UPLOAD VALIDATOR] Initialisation - ClamAVEnabled=%v, ClamAVRequired=%v\n", config.ClamAVEnabled, config.ClamAVRequired) + + // EARLY RETURN: Si ClamAV est désactivé, ne JAMAIS toucher au réseau ClamAV + if !config.ClamAVEnabled { + fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV désactivé - Aucune connexion réseau ne sera tentée\n") + logger.Info("ClamAV is disabled - virus scanning will be skipped", + zap.Bool("clamav_enabled", config.ClamAVEnabled), + zap.Bool("clamav_required", config.ClamAVRequired), + ) + return &UploadValidator{ + logger: logger, + clamdClient: nil, // Explicitement nil + quarantineDir: config.QuarantineDir, + clamAVRequiredButUnavailable: false, + clamAVRequired: config.ClamAVRequired, + }, nil + } + + // ClamAV est activé - initialiser le client + fmt.Printf("🛡️ [UPLOAD VALIDATOR] ClamAV activé - Initialisation client sur %s\n", config.ClamAVAddress) var clamdClient *clamd.Clamd clamAVRequiredButUnavailable := false - if config.ClamAVEnabled { - clamdClient = clamd.NewClamd(config.ClamAVAddress) - // Test connection - MOD-P1-001-REFINEMENT: Ne pas bloquer le démarrage, mais flag pour fail-secure - if err := clamdClient.Ping(); err != nil { - if config.ClamAVRequired { - // MOD-P1-002: Si ClamAV est requis, rejeter les uploads (fail-secure) - logger.Warn("ClamAV is enabled and required but unavailable - uploads will be rejected until ClamAV is available", - zap.Error(err), - zap.String("address", config.ClamAVAddress), - ) - clamAVRequiredButUnavailable = true - } else { - // MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads avec warning (mode dégradé) - logger.Warn("ClamAV is enabled but unavailable and not required - uploads will be accepted without virus scanning (degraded mode)", - zap.Error(err), - zap.String("address", config.ClamAVAddress), - zap.String("security_warning", "Virus scanning is disabled. This should only be used in development or with alternative security measures."), - ) - clamAVRequiredButUnavailable = false - } - // Ne pas retourner d'erreur - le serveur peut démarrer - } else { - logger.Info("ClamAV connection successful") - } + // Créer le client ClamAV (cela ne devrait PAS faire de connexion immédiate) + clamdClient = clamd.NewClamd(config.ClamAVAddress) + fmt.Printf("🔌 [UPLOAD VALIDATOR] Client ClamAV créé - Test de connexion (Ping avec timeout 2s)...\n") + + // Test connection avec timeout pour éviter blocage - MOD-P1-001-REFINEMENT: Ne pas bloquer le démarrage + // Créer un contexte avec timeout court pour le Ping + pingCtx, pingCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer pingCancel() + + // Lancer le Ping dans une goroutine avec timeout + pingDone := make(chan error, 1) + go func() { + pingDone <- clamdClient.Ping() + }() + + var err error + select { + case err = <-pingDone: + // Ping terminé (succès ou erreur) + fmt.Printf("🔌 [UPLOAD VALIDATOR] Ping terminé - erreur: %v\n", err) + case <-pingCtx.Done(): + // Timeout - ClamAV ne répond pas + err = fmt.Errorf("ClamAV ping timeout after 2s: %w", pingCtx.Err()) + fmt.Printf("⏱️ [UPLOAD VALIDATOR] Ping timeout - ClamAV ne répond pas\n") } + if err != nil { + fmt.Printf("⚠️ [UPLOAD VALIDATOR] ClamAV Ping échoué: %v\n", err) + if config.ClamAVRequired { + // MOD-P1-002: Si ClamAV est requis, rejeter les uploads (fail-secure) + logger.Warn("ClamAV is enabled and required but unavailable - uploads will be rejected until ClamAV is available", + zap.Error(err), + zap.String("address", config.ClamAVAddress), + ) + clamAVRequiredButUnavailable = true + } else { + // MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads avec warning (mode dégradé) + logger.Warn("ClamAV is enabled but unavailable and not required - uploads will be accepted without virus scanning (degraded mode)", + zap.Error(err), + zap.String("address", config.ClamAVAddress), + zap.String("security_warning", "Virus scanning is disabled. This should only be used in development or with alternative security measures."), + ) + clamAVRequiredButUnavailable = false + } + // Ne pas retourner d'erreur - le serveur peut démarrer + } else { + fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV Ping réussi - Connexion OK\n") + logger.Info("ClamAV connection successful") + } + + fmt.Printf("✅ [UPLOAD VALIDATOR] Validateur initialisé - clamdClient=%v, requiredButUnavailable=%v\n", + clamdClient != nil, clamAVRequiredButUnavailable) + return &UploadValidator{ logger: logger, clamdClient: clamdClient, @@ -139,6 +186,16 @@ type ValidationResult struct { // ValidateFile valide un fichier uploadé // MOD-P1-001: Le scan ClamAV se fait AVANT toute persistance func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipart.FileHeader, fileType string) (*ValidationResult, error) { + // DEBUG: Log de début de validation + fmt.Printf("🚀 [UPLOAD VALIDATE] Début validation fichier: %s (taille: %d bytes, type: %s)\n", fileHeader.Filename, fileHeader.Size, fileType) + fmt.Printf("🔍 [UPLOAD VALIDATE] État ClamAV - client=%v, required=%v, requiredButUnavailable=%v\n", + uv.clamdClient != nil, uv.clamAVRequired, uv.clamAVRequiredButUnavailable) + + // EARLY CHECK: Si ClamAV est complètement désactivé, on skip immédiatement + if uv.clamdClient == nil { + fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé (client=nil) - scan antivirus ignoré\n") + } + result := &ValidationResult{ FileSize: fileHeader.Size, } @@ -146,10 +203,12 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa // Ouvrir le fichier file, err := fileHeader.Open() if err != nil { + fmt.Printf("❌ [UPLOAD] Erreur ouverture fichier: %v\n", err) result.Error = "Failed to open file" return result, err } defer file.Close() + fmt.Printf("✅ [UPLOAD] Fichier ouvert avec succès\n") // Lire les premiers bytes pour vérifier le magic number header := make([]byte, 512) @@ -207,9 +266,11 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa // Scanner avec ClamAV si disponible if uv.clamdClient != nil { + fmt.Printf("🛡️ [UPLOAD VALIDATE] ClamAV activé - Début scan antivirus...\n") file.Seek(0, 0) scanResult, err := uv.scanWithClamAV(ctx, file) if err != nil { + fmt.Printf("❌ [UPLOAD] Erreur scan ClamAV: %v\n", err) uv.logger.Error("ClamAV scan failed", zap.Error(err), zap.String("filename", fileHeader.Filename), @@ -221,6 +282,7 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa // MOD-P1-001: Si virus détecté, rejeter immédiatement (code 422) if scanResult != nil && scanResult.Status != "OK" { + fmt.Printf("⚠️ [UPLOAD] Virus détecté: %s\n", scanResult.Description) result.Quarantined = true result.Error = fmt.Sprintf("Virus detected: %s", scanResult.Description) uv.logger.Warn("Virus detected in uploaded file", @@ -230,10 +292,13 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa return result, fmt.Errorf("clamav_infected: %s", scanResult.Description) } + fmt.Printf("✅ [UPLOAD VALIDATE] Scan ClamAV réussi - fichier propre\n") uv.logger.Info("File scanned successfully with ClamAV", zap.String("filename", fileHeader.Filename), zap.String("status", "clean"), ) + } else { + fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé (client=nil) - scan ignoré, validation continue\n") } // Valider l'extension du fichier @@ -244,6 +309,7 @@ func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipa } result.Valid = true + fmt.Printf("✅ [UPLOAD VALIDATE] Validation complète - fichier accepté (Valid=true)\n") return result, nil }