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

442 lines
18 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")
// 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) sendDeactivationEmail(toEmail, toName string) error {
subject := "Your Sojorn account has been deactivated"
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: 1px solid #334155;">
<h1 style="color: #f59e0b; font-size: 22px; margin: 0 0 24px 0;">Account Deactivated</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been deactivated. Your profile is now hidden from other users.</p>
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 14px; margin: 0 0 8px 0; font-weight: bold;">What this means:</p>
<ul style="color: #94a3b8; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Your profile, posts, and connections are hidden but <strong>fully preserved</strong></li>
<li>No one can see your account while it is deactivated</li>
<li>You can reactivate at any time simply by <strong>logging back in</strong></li>
</ul>
</div>
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to reactivate your account and secure it by changing your password.</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)
textBody := fmt.Sprintf(`Account Deactivated
Hey %s,
Your Sojorn account has been deactivated. Your profile is now hidden from other users.
What this means:
- Your profile, posts, and connections are hidden but fully preserved
- No one can see your account while it is deactivated
- You can reactivate at any time simply by logging back in
If you did not request this, please log back in immediately to reactivate your account and change your password.
MPLS LLC - Sojorn - Minneapolis, MN`, toName)
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
}
func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
subject := "Your Sojorn account is scheduled for deletion"
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 #ef4444;">
<h1 style="color: #ef4444; font-size: 22px; margin: 0 0 24px 0;">Account Deletion Scheduled</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been scheduled for <strong>permanent deletion on %s</strong>.</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;">What happens next:</p>
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Your account is immediately deactivated and hidden</li>
<li>On <strong>%s</strong>, all data will be permanently and irreversibly destroyed</li>
<li>This includes posts, messages, encryption keys, profile, followers, and your handle</li>
</ul>
</div>
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">Changed your mind?</p>
<p style="color: #94a3b8; font-size: 14px; margin: 0; line-height: 1.6;">Simply <strong>log back in</strong> before %s to cancel the deletion and reactivate your account. All your data is still intact during the grace period.</p>
</div>
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to cancel the deletion and secure your account.</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, deletionDate, deletionDate, deletionDate)
textBody := fmt.Sprintf(`Account Deletion Scheduled
Hey %s,
Your Sojorn account has been scheduled for permanent deletion on %s.
What happens next:
- Your account is immediately deactivated and hidden
- On %s, all data will be permanently and irreversibly destroyed
- This includes posts, messages, encryption keys, profile, followers, and your handle
Changed your mind?
Simply log back in before %s to cancel the deletion and reactivate your account.
If you did not request this, please log back in immediately to cancel the deletion and change your password.
MPLS LLC - Sojorn - Minneapolis, MN`, toName, deletionDate, deletionDate, deletionDate)
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
}
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)
}