442 lines
18 KiB
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 · Sojorn · 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 · Sojorn · 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 · Sojorn · 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)
|
|
}
|