- 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
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/utils"
|
|
)
|
|
|
|
// DeleteAccountRequest is the request body for account deletion (v0.10.8 F065)
|
|
type DeleteAccountRequest struct {
|
|
Password string `json:"password" binding:"required"`
|
|
Reason string `json:"reason"`
|
|
ConfirmText string `json:"confirm_text" binding:"required"`
|
|
KeepPublicTracks bool `json:"keep_public_tracks"` // If true, public tracks remain (attributed to deleted account)
|
|
}
|
|
|
|
// DeleteAccountHandler returns a handler for DELETE /users/me (v0.803 SEC2-05)
|
|
//
|
|
// @Summary Delete account
|
|
// @Description Permanently delete user account with anonymization, session revocation, audit log
|
|
// @Tags Users
|
|
// @Security BearerAuth
|
|
// @Param body body DeleteAccountRequest true "Password, reason, confirm_text (must be DELETE)"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} map[string]interface{}
|
|
// @Failure 401 {object} map[string]interface{}
|
|
// @Failure 500 {object} map[string]interface{}
|
|
// @Router /users/me [delete]
|
|
func DeleteAccountHandler(
|
|
db *gorm.DB,
|
|
sessionService *services.SessionService,
|
|
auditService *services.AuditService,
|
|
s3Service *services.S3StorageService,
|
|
logger *zap.Logger,
|
|
) gin.HandlerFunc {
|
|
refreshTokenService := services.NewRefreshTokenService(db)
|
|
|
|
return func(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok || userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
return
|
|
}
|
|
|
|
var req DeleteAccountRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: password and confirm_text required"})
|
|
return
|
|
}
|
|
|
|
if req.ConfirmText != "DELETE" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Type DELETE to confirm"})
|
|
return
|
|
}
|
|
|
|
// 1. Verify password
|
|
var passwordHash string
|
|
if err := db.WithContext(c.Request.Context()).Raw("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&passwordHash).Error; err != nil || passwordHash == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
|
|
return
|
|
}
|
|
if err := utils.CheckPasswordHash(req.Password, passwordHash); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
recoveryDeadline := time.Now().Add(30 * 24 * time.Hour)
|
|
anonUsername := "deleted-" + userID.String()
|
|
anonEmail := "deleted-" + userID.String() + "@veza.app"
|
|
|
|
// 2. Soft delete + full PII anonymization (v0.10.8 droit à l'oubli)
|
|
result := db.WithContext(ctx).Exec(`
|
|
UPDATE users SET
|
|
is_active = false,
|
|
deleted_at = NOW(),
|
|
deletion_reason = ?,
|
|
recovery_deadline = ?,
|
|
username = ?,
|
|
email = ?,
|
|
first_name = NULL,
|
|
last_name = NULL,
|
|
bio = '',
|
|
location = '',
|
|
avatar = '',
|
|
banner_url = '',
|
|
updated_at = NOW()
|
|
WHERE id = ?
|
|
`, req.Reason, recoveryDeadline, anonUsername, anonEmail, userID)
|
|
if result.Error != nil {
|
|
logger.Error("Failed to delete account", zap.Error(result.Error), zap.String("user_id", userID.String()))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete account"})
|
|
return
|
|
}
|
|
|
|
// 2b. Anonymize user_profiles if exists (v0.10.8)
|
|
_ = db.WithContext(ctx).Exec("UPDATE user_profiles SET bio = NULL, tagline = NULL, location = NULL, website_url = NULL, avatar_url = NULL, banner_url = NULL, updated_at = NOW() WHERE user_id = ?", userID)
|
|
|
|
// 2c. Tracks: if !KeepPublicTracks, soft-delete all user tracks (v0.10.8)
|
|
if !req.KeepPublicTracks {
|
|
_ = db.WithContext(ctx).Exec("UPDATE tracks SET deleted_at = NOW() WHERE creator_id = ?", userID)
|
|
}
|
|
|
|
// 3. Revoke all sessions
|
|
if sessionService != nil {
|
|
if _, err := sessionService.RevokeAllUserSessions(ctx, userID); err != nil {
|
|
logger.Warn("Failed to revoke sessions during account deletion", zap.Error(err), zap.String("user_id", userID.String()))
|
|
}
|
|
}
|
|
|
|
// 4. Revoke all refresh tokens
|
|
if err := refreshTokenService.RevokeAll(userID); err != nil {
|
|
logger.Warn("Failed to revoke refresh tokens during account deletion", zap.Error(err), zap.String("user_id", userID.String()))
|
|
}
|
|
|
|
// 5. S3 cleanup (cloud files for user)
|
|
if s3Service != nil {
|
|
deleteUserS3Files(ctx, db, s3Service, userID, logger)
|
|
}
|
|
|
|
// 6. Audit log
|
|
if auditService != nil {
|
|
_ = auditService.LogDeletion(ctx, userID, "user", userID, c.ClientIP(), c.Request.UserAgent())
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Account deleted"})
|
|
}
|
|
}
|
|
|
|
// deleteUserS3Files deletes cloud files for a user from S3
|
|
func deleteUserS3Files(ctx context.Context, db *gorm.DB, s3Service *services.S3StorageService, userID uuid.UUID, logger *zap.Logger) {
|
|
var files []struct {
|
|
S3Key string
|
|
}
|
|
if err := db.WithContext(ctx).Raw("SELECT s3_key FROM user_files WHERE user_id = ?", userID).Scan(&files).Error; err != nil {
|
|
logger.Warn("Failed to list user files for S3 cleanup", zap.Error(err), zap.String("user_id", userID.String()))
|
|
return
|
|
}
|
|
for _, f := range files {
|
|
if err := s3Service.DeleteFile(ctx, f.S3Key); err != nil {
|
|
logger.Warn("Failed to delete S3 file during account deletion", zap.Error(err), zap.String("key", f.S3Key))
|
|
}
|
|
}
|
|
}
|