275 lines
9.1 KiB
Go
275 lines
9.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/patbritton/sojorn-backend/internal/config"
|
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
|
"github.com/patbritton/sojorn-backend/internal/services"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type AccountHandler struct {
|
|
repo *repository.UserRepository
|
|
emailService *services.EmailService
|
|
config *config.Config
|
|
}
|
|
|
|
func NewAccountHandler(repo *repository.UserRepository, emailService *services.EmailService, cfg *config.Config) *AccountHandler {
|
|
return &AccountHandler{repo: repo, emailService: emailService, config: cfg}
|
|
}
|
|
|
|
// DeactivateAccount sets the user's status to deactivated.
|
|
// All data is preserved indefinitely. User can reactivate by logging in.
|
|
func (h *AccountHandler) DeactivateAccount(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID := userIDStr.(string)
|
|
|
|
user, err := h.repo.GetUserByID(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
|
|
return
|
|
}
|
|
|
|
if user.Status == "deactivated" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Account is already deactivated"})
|
|
return
|
|
}
|
|
|
|
if err := h.repo.DeactivateUser(c.Request.Context(), userID); err != nil {
|
|
log.Error().Err(err).Str("user_id", userID).Msg("Failed to deactivate account")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deactivate account"})
|
|
return
|
|
}
|
|
|
|
// Revoke all tokens so they're logged out everywhere
|
|
_ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
|
|
|
|
// Send notification email
|
|
profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
|
|
displayName := "there"
|
|
if profile != nil && profile.DisplayName != nil {
|
|
displayName = *profile.DisplayName
|
|
}
|
|
go func() {
|
|
if err := h.sendDeactivationEmail(user.Email, displayName); err != nil {
|
|
log.Error().Err(err).Str("user_id", userID).Msg("Failed to send deactivation email")
|
|
}
|
|
}()
|
|
|
|
log.Info().Str("user_id", userID).Msg("Account deactivated")
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Your account has been deactivated. All your data is preserved. You can reactivate at any time by logging back in.",
|
|
"status": "deactivated",
|
|
})
|
|
}
|
|
|
|
// DeleteAccount schedules the account for deletion after 14 days.
|
|
// During the grace period, the user can cancel by logging back in.
|
|
func (h *AccountHandler) DeleteAccount(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID := userIDStr.(string)
|
|
|
|
if err := h.repo.ScheduleDeletion(c.Request.Context(), userID); err != nil {
|
|
log.Error().Err(err).Str("user_id", userID).Msg("Failed to schedule account deletion")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule deletion"})
|
|
return
|
|
}
|
|
|
|
// Revoke all tokens
|
|
_ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
|
|
|
|
deletionDate := time.Now().Add(14 * 24 * time.Hour).Format("January 2, 2006")
|
|
|
|
// Send notification email
|
|
user, err := h.repo.GetUserByID(c.Request.Context(), userID)
|
|
if err == nil {
|
|
profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
|
|
displayName := "there"
|
|
if profile != nil && profile.DisplayName != nil {
|
|
displayName = *profile.DisplayName
|
|
}
|
|
go func() {
|
|
if err := h.sendDeletionScheduledEmail(user.Email, displayName, deletionDate); err != nil {
|
|
log.Error().Err(err).Str("user_id", userID).Msg("Failed to send deletion email")
|
|
}
|
|
}()
|
|
}
|
|
|
|
log.Info().Str("user_id", userID).Msg("Account scheduled for deletion in 14 days")
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": fmt.Sprintf("Your account is scheduled for permanent deletion on %s. Log back in before then to cancel. After that date, all data will be irreversibly destroyed.", deletionDate),
|
|
"status": "pending_deletion",
|
|
"deletion_date": deletionDate,
|
|
})
|
|
}
|
|
|
|
// RequestImmediateDestroy initiates the super-delete flow.
|
|
// Sends a confirmation email with a one-time token. Nothing is deleted yet.
|
|
func (h *AccountHandler) RequestImmediateDestroy(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID := userIDStr.(string)
|
|
|
|
user, err := h.repo.GetUserByID(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
|
|
return
|
|
}
|
|
|
|
profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
|
|
displayName := "there"
|
|
if profile != nil && profile.DisplayName != nil {
|
|
displayName = *profile.DisplayName
|
|
}
|
|
|
|
// Generate a secure one-time token
|
|
rawToken, err := generateDestroyToken()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate confirmation token"})
|
|
return
|
|
}
|
|
|
|
hash := sha256.Sum256([]byte(rawToken))
|
|
hashString := hex.EncodeToString(hash[:])
|
|
|
|
// Store as auth_token with type 'destroy_confirm', expires in 1 hour
|
|
if err := h.repo.CreateVerificationToken(c.Request.Context(), hashString, userID, 1*time.Hour); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store confirmation token"})
|
|
return
|
|
}
|
|
|
|
// Send the confirmation email with strong language
|
|
go func() {
|
|
if err := h.sendDestroyConfirmationEmail(user.Email, displayName, rawToken); err != nil {
|
|
log.Error().Err(err).Str("user_id", userID).Msg("Failed to send destroy confirmation email")
|
|
}
|
|
}()
|
|
|
|
log.Warn().Str("user_id", userID).Msg("Immediate destroy requested — confirmation email sent")
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "A confirmation email has been sent. You must click the link in that email to permanently and immediately destroy your account. This action cannot be undone.",
|
|
})
|
|
}
|
|
|
|
// ConfirmImmediateDestroy is the endpoint hit from the email confirmation link.
|
|
// It verifies the token and permanently purges all user data.
|
|
func (h *AccountHandler) ConfirmImmediateDestroy(c *gin.Context) {
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing confirmation token"})
|
|
return
|
|
}
|
|
|
|
hash := sha256.Sum256([]byte(token))
|
|
hashString := hex.EncodeToString(hash[:])
|
|
|
|
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
|
|
if err != nil {
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid")
|
|
return
|
|
}
|
|
|
|
if time.Now().After(expiresAt) {
|
|
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired")
|
|
return
|
|
}
|
|
|
|
// Clean up the token
|
|
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
|
|
|
// Revoke all tokens first
|
|
_ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
|
|
|
|
// CASCADE PURGE — point of no return
|
|
if err := h.repo.CascadePurgeUser(c.Request.Context(), userID); err != nil {
|
|
log.Error().Err(err).Str("user_id", userID).Msg("CRITICAL: Cascade purge failed")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Account destruction failed. Please contact support."})
|
|
return
|
|
}
|
|
|
|
log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged")
|
|
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/destroyed")
|
|
}
|
|
|
|
// GetAccountStatus returns the current account lifecycle status
|
|
func (h *AccountHandler) GetAccountStatus(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID := userIDStr.(string)
|
|
|
|
user, err := h.repo.GetUserByID(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
|
|
return
|
|
}
|
|
|
|
result := gin.H{
|
|
"status": user.Status,
|
|
}
|
|
|
|
if user.DeletedAt != nil {
|
|
result["deletion_scheduled"] = user.DeletedAt.Format(time.RFC3339)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// CancelDeletion allows a user to cancel a pending deletion
|
|
func (h *AccountHandler) CancelDeletion(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID := userIDStr.(string)
|
|
|
|
user, err := h.repo.GetUserByID(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
|
|
return
|
|
}
|
|
|
|
if string(user.Status) != "pending_deletion" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Account is not pending deletion"})
|
|
return
|
|
}
|
|
|
|
if err := h.repo.CancelDeletion(c.Request.Context(), userID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cancel deletion"})
|
|
return
|
|
}
|
|
|
|
log.Info().Str("user_id", userID).Msg("Account deletion cancelled, reactivated")
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Deletion cancelled. Your account is active again.",
|
|
"status": "active",
|
|
})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func generateDestroyToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func (h *AccountHandler) sendDeactivationEmail(toEmail, toName string) error {
|
|
return h.emailService.SendDeactivationEmail(toEmail, toName)
|
|
}
|
|
|
|
func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
|
|
return h.emailService.SendDeletionScheduledEmail(toEmail, toName, deletionDate)
|
|
}
|
|
|
|
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
|
|
confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
|
|
h.config.APIBaseURL, token)
|
|
return h.emailService.SendDestroyConfirmationEmail(toEmail, toName, confirmURL)
|
|
}
|