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