veza/veza-backend-api/internal/handlers/gdpr_export_handler.go
senke ef386e0ae3 fix(backend): commit swagger annotation pass + missing handler methods
routes_users.go (already on main) calls settingsHandler.GetPreferences /
UpdatePreferences and gdprExportHandler.ExportJSON, but the methods only
existed in the working tree — main wouldn't compile, so deploy.yml's
build-backend job was stuck on the same compile error every run.

Bundles the WIP swagger annotation sweep across chat / marketplace /
role / settings / gdpr / etc. handlers with the regenerated swagger.json,
swagger.yaml, docs.go and openapi.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:16:57 +02:00

299 lines
10 KiB
Go

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