package handlers import ( "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "net/http" "time" "github.com/gin-gonic/gin" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository" "gitlab.com/patrickbritton3/sojorn/go-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) }