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 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 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 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 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) }