sojorn/go-backend/internal/handlers/account_handler.go

307 lines
12 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)
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")
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")
// Return a simple HTML goodbye page (this is accessed via browser from email link)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Account Destroyed</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
.card{max-width:480px;background:#16213e;border-radius:16px;padding:48px;text-align:center;}
h1{color:#dc2626;font-size:24px;}p{line-height:1.6;color:#a0a0b0;}</style></head>
<body><div class="card"><h1>Account Destroyed</h1>
<p>Your account and all associated data have been permanently destroyed. This action cannot be undone.</p>
<p style="margin-top:32px;color:#666;">Goodbye. — Sojorn</p></div></body></html>`))
}
// 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) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
subject := "FINAL WARNING: Confirm Permanent Account Destruction"
confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
h.config.APIBaseURL, token)
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #dc2626;">
<h1 style="color: #dc2626; font-size: 24px; margin: 0 0 24px 0;">⚠ Account Destruction Confirmation</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">You requested <strong>immediate and permanent destruction</strong> of your Sojorn account.</p>
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">THIS ACTION IS IRREVERSIBLE</p>
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>All your posts, comments, and media will be permanently deleted</li>
<li>All your messages and encryption keys will be destroyed</li>
<li>Your profile, followers, and social connections will be erased</li>
<li>Your handle will be released and cannot be reclaimed</li>
<li><strong>There is no recovery. No backup. No undo.</strong></li>
</ul>
</div>
<p style="font-size: 16px; line-height: 1.6;">If you are absolutely certain, click the button below. <strong>Your account will be destroyed the instant you click.</strong></p>
<div style="text-align: center; margin: 32px 0;">
<a href="%s" style="background: #dc2626; color: white; padding: 16px 40px; border-radius: 8px; text-decoration: none; font-weight: bold; font-size: 16px; display: inline-block;">PERMANENTLY DESTROY MY ACCOUNT</a>
</div>
<p style="font-size: 13px; color: #888; line-height: 1.6;">This link expires in 1 hour. If you did not request this, you can safely ignore this email — your account will not be affected.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, toName, confirmURL)
textBody := fmt.Sprintf(`FINAL WARNING: Confirm Permanent Account Destruction
Hey %s,
You requested IMMEDIATE AND PERMANENT DESTRUCTION of your Sojorn account.
THIS ACTION IS IRREVERSIBLE:
- All posts, comments, and media permanently deleted
- All messages and encryption keys destroyed
- Profile, followers, and connections erased
- Handle released and cannot be reclaimed
- THERE IS NO RECOVERY. NO BACKUP. NO UNDO.
To confirm, visit: %s
This link expires in 1 hour. If you did not request this, ignore this email.
MPLS LLC - Sojorn - Minneapolis, MN`, toName, confirmURL)
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
}