package handlers import ( "fmt" "net/http" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" "veza-backend-api/internal/services" ) const exportRateLimitKeyPrefix = "gdpr:export:" const exportRateLimitWindow = 24 * time.Hour const exportRateLimitMax = 3 // GDPRExportHandler handles GDPR export endpoints (v0.10.8 F065) type GDPRExportHandler struct { db *gorm.DB gdprExportService *services.GDPRExportService s3Service *services.S3StorageService redisClient *redis.Client logger *zap.Logger } // NewGDPRExportHandler creates a new GDPR export handler func NewGDPRExportHandler( db *gorm.DB, gdprExportService *services.GDPRExportService, s3Service *services.S3StorageService, redisClient *redis.Client, logger *zap.Logger, ) *GDPRExportHandler { return &GDPRExportHandler{ db: db, gdprExportService: gdprExportService, s3Service: s3Service, redisClient: redisClient, logger: logger, } } // RequestExport starts an async GDPR export // @Summary Request GDPR Export // @Description Start an asynchronous export of all user data. User will receive an email when ready. // @Tags User // @Accept json // @Produce json // @Success 202 {object} handlers.APIResponse{data=object{export_id=string,status=string}} // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 429 {object} handlers.APIResponse "Too Many Requests" // @Router /users/me/export [post] func (h *GDPRExportHandler) RequestExport(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } // Rate limit: 3 exports per 24h if h.redisClient != nil { key := exportRateLimitKeyPrefix + userID.String() count, err := h.redisClient.Incr(c.Request.Context(), key).Result() if err == nil { if count == 1 { h.redisClient.Expire(c.Request.Context(), key, exportRateLimitWindow) } if count > exportRateLimitMax { c.JSON(http.StatusTooManyRequests, gin.H{ "error": "Export limit reached. You can request up to 3 exports per day.", }) return } } } expiresAt := time.Now().Add(7 * 24 * time.Hour) export := models.DataExport{ UserID: userID, Status: "pending", ExpiresAt: expiresAt, } if err := h.db.WithContext(c.Request.Context()).Create(&export).Error; err != nil { h.logger.Error("Failed to create export record", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export"}) return } h.gdprExportService.ExportUserDataAsync(c.Request.Context(), export.ID, userID) c.JSON(http.StatusAccepted, gin.H{ "export_id": export.ID.String(), "status": "processing", "estimated_completion": time.Now().Add(15 * time.Minute).Format(time.RFC3339), "message": "Your export is being prepared. You will receive an email with the download link when ready.", }) } // ListExports returns the user's recent exports // @Summary List GDPR Exports // @Description Get a list of recent GDPR data exports for the authenticated user // @Tags User // @Accept json // @Produce json // @Success 200 {object} handlers.APIResponse{data=object{exports=array}} // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Router /users/me/exports [get] func (h *GDPRExportHandler) ListExports(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } var exports []models.DataExport if err := h.db.WithContext(c.Request.Context()).Where("user_id = ?", userID). Order("created_at DESC").Limit(10).Find(&exports).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list exports"}) return } result := make([]gin.H, len(exports)) for i, e := range exports { result[i] = gin.H{ "id": e.ID.String(), "status": e.Status, "expires_at": e.ExpiresAt.Format(time.RFC3339), "created_at": e.CreatedAt.Format(time.RFC3339), "completed_at": nil, } if e.CompletedAt != nil { result[i]["completed_at"] = e.CompletedAt.Format(time.RFC3339) } if e.FileSizeBytes != nil { result[i]["file_size_bytes"] = *e.FileSizeBytes } } c.JSON(http.StatusOK, gin.H{"exports": result}) } // GetExport returns the status of a specific export // @Summary Get GDPR Export Status // @Description Get the status and download URL of a specific GDPR export // @Tags User // @Accept json // @Produce json // @Param id path string true "Export ID" // @Success 200 {object} handlers.APIResponse{data=object} // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 404 {object} handlers.APIResponse "Export not found" // @Router /users/me/exports/{id} [get] func (h *GDPRExportHandler) GetExport(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } exportIDStr := c.Param("id") exportID, err := uuid.Parse(exportIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid export ID"}) return } var export models.DataExport if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", exportID, userID).First(&export).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Export not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get export"}) return } resp := gin.H{ "export_id": export.ID.String(), "status": export.Status, "expires_at": export.ExpiresAt.Format(time.RFC3339), "created_at": export.CreatedAt.Format(time.RFC3339), } if export.CompletedAt != nil { resp["completed_at"] = export.CompletedAt.Format(time.RFC3339) } if export.FileSizeBytes != nil { resp["file_size_bytes"] = *export.FileSizeBytes } if export.Status == "completed" && export.S3Key != nil && h.s3Service != nil { url, err := h.s3Service.GetPresignedURL(c.Request.Context(), *export.S3Key) if err == nil { resp["download_url"] = url } } c.JSON(http.StatusOK, resp) } // DownloadExport redirects to the presigned S3 URL // @Summary Download GDPR Export // @Description Redirect to a temporary download URL for a completed GDPR export // @Tags User // @Accept json // @Produce json // @Param id path string true "Export ID" // @Success 302 {string} string "Redirect to S3" // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 404 {object} handlers.APIResponse "Export not found" // @Failure 410 {object} handlers.APIResponse "Export expired" // @Router /users/me/exports/{id}/download [get] func (h *GDPRExportHandler) DownloadExport(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } exportIDStr := c.Param("id") exportID, err := uuid.Parse(exportIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid export ID"}) return } var export models.DataExport if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", exportID, userID).First(&export).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Export not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get export"}) return } if export.Status != "completed" { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Export is not ready (status: %s)", export.Status)}) return } if export.ExpiresAt.Before(time.Now()) { c.JSON(http.StatusGone, gin.H{"error": "Export has expired"}) return } if export.S3Key == nil || *export.S3Key == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "Export file not available"}) return } if h.s3Service == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Download service unavailable"}) return } url, err := h.s3Service.GetPresignedURL(c.Request.Context(), *export.S3Key) if err != nil { h.logger.Error("Failed to get presigned URL", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate download link"}) return } c.Redirect(http.StatusFound, url) } // ExportJSON returns user data as JSON (legacy synchronous fallback) // @Summary Export User Data (JSON) // @Description Synchronously export all user data in JSON format. // @Tags User // @Accept json // @Produce json // @Success 200 {object} object "JSON Data Export" // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 500 {object} handlers.APIResponse "Internal Error" // @Router /users/me/export [get] func (h *GDPRExportHandler) ExportJSON(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "User ID not found"}) return } // Utiliser le service d'export pour générer le JSON dataExportService := services.NewDataExportService(h.db, h.logger) jsonData, err := dataExportService.ExportUserDataAsJSON(c.Request.Context(), userID) if err != nil { h.logger.Error("Failed to export user data", zap.Error(err), zap.String("user_id", userID.String())) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export user data"}) return } filename := "veza-data-export-" + time.Now().Format("2006-01-02T15-04-05") + ".json" c.Header("Content-Type", "application/json") c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) c.Header("Content-Length", fmt.Sprintf("%d", len(jsonData))) c.Data(http.StatusOK, "application/json", jsonData) }