veza/veza-backend-api/internal/handlers/gdpr_export_handler.go
senke 19fec9e40a
Some checks failed
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s
Backend API CI / test-unit (push) Failing after 0s
feat(gdpr): v0.10.8 portabilité données - export ZIP async, suppression compte, hard delete cron
- Export: table data_exports, POST /me/export (202), GET /me/exports, messages+playback_history
- Notification email quand ZIP prêt, rate limit 3/jour
- Suppression: keep_public_tracks, anonymisation PII complète (users, user_profiles)
- HardDeleteWorker: final anonymization après 30 jours
- Frontend: POST export, checkbox keep_public_tracks
- MSW handlers pour Storybook
2026-03-10 13:57:04 +01:00

227 lines
6.7 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
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)
}