feat: implement account deactivation, deletion (14-day), and immediate destroy with email confirmation
This commit is contained in:
parent
95056aee82
commit
ecc02e10cc
|
|
@ -160,6 +160,8 @@ func main() {
|
||||||
|
|
||||||
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||||
|
|
||||||
|
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
||||||
|
|
||||||
mediaHandler := handlers.NewMediaHandler(
|
mediaHandler := handlers.NewMediaHandler(
|
||||||
s3Client,
|
s3Client,
|
||||||
cfg.R2AccountID,
|
cfg.R2AccountID,
|
||||||
|
|
@ -360,6 +362,16 @@ func main() {
|
||||||
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
|
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
|
||||||
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices)
|
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices)
|
||||||
|
|
||||||
|
// Account Lifecycle routes
|
||||||
|
account := authorized.Group("/account")
|
||||||
|
{
|
||||||
|
account.GET("/status", accountHandler.GetAccountStatus)
|
||||||
|
account.POST("/deactivate", accountHandler.DeactivateAccount)
|
||||||
|
account.DELETE("", accountHandler.DeleteAccount)
|
||||||
|
account.POST("/cancel-deletion", accountHandler.CancelDeletion)
|
||||||
|
account.POST("/destroy", accountHandler.RequestImmediateDestroy)
|
||||||
|
}
|
||||||
|
|
||||||
// Appeal System routes
|
// Appeal System routes
|
||||||
appeals := authorized.Group("/appeals")
|
appeals := authorized.Group("/appeals")
|
||||||
{
|
{
|
||||||
|
|
@ -457,6 +469,9 @@ func main() {
|
||||||
// Public claim request endpoint (no auth)
|
// Public claim request endpoint (no auth)
|
||||||
r.POST("/api/v1/username-claim", adminHandler.SubmitClaimRequest)
|
r.POST("/api/v1/username-claim", adminHandler.SubmitClaimRequest)
|
||||||
|
|
||||||
|
// Account destroy confirmation (accessed via email link, no auth)
|
||||||
|
r.GET("/api/v1/account/destroy/confirm", accountHandler.ConfirmImmediateDestroy)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
|
|
@ -468,6 +483,27 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Background job: purge accounts past 14-day deletion window (runs every hour)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
ids, err := userRepo.GetAccountsPendingPurge(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("[Purge] Failed to fetch accounts pending purge")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
log.Warn().Str("user_id", id).Msg("[Purge] Auto-purging account past 14-day grace period")
|
||||||
|
if err := userRepo.CascadePurgeUser(context.Background(), id); err != nil {
|
||||||
|
log.Error().Err(err).Str("user_id", id).Msg("[Purge] FAILED to purge account")
|
||||||
|
} else {
|
||||||
|
log.Info().Str("user_id", id).Msg("[Purge] Account permanently destroyed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
log.Info().Msgf("Server started on port %s", cfg.Port)
|
log.Info().Msgf("Server started on port %s", cfg.Port)
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
|
|
|
||||||
306
go-backend/internal/handlers/account_handler.go
Normal file
306
go-backend/internal/handlers/account_handler.go
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
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 · 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)
|
||||||
|
}
|
||||||
|
|
@ -267,9 +267,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.Status == models.UserStatusDeactivated {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"})
|
// Auto-reactivate deactivated or pending-deletion accounts on login
|
||||||
return
|
if user.Status == models.UserStatusDeactivated || user.Status == models.UserStatusPendingDeletion {
|
||||||
|
log.Printf("[Auth] Reactivating %s account for %s", user.Status, req.Email)
|
||||||
|
_ = h.repo.ReactivateUser(c.Request.Context(), user.ID.String())
|
||||||
|
user.Status = models.UserStatusActive
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.MFAEnabled {
|
if user.MFAEnabled {
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ import (
|
||||||
type UserStatus string
|
type UserStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UserStatusPending UserStatus = "pending"
|
UserStatusPending UserStatus = "pending"
|
||||||
UserStatusActive UserStatus = "active"
|
UserStatusActive UserStatus = "active"
|
||||||
UserStatusDeactivated UserStatus = "deactivated"
|
UserStatusDeactivated UserStatus = "deactivated"
|
||||||
UserStatusBanned UserStatus = "banned"
|
UserStatusPendingDeletion UserStatus = "pending_deletion"
|
||||||
UserStatusSuspended UserStatus = "suspended"
|
UserStatusBanned UserStatus = "banned"
|
||||||
|
UserStatusSuspended UserStatus = "suspended"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|
|
||||||
|
|
@ -1328,3 +1328,166 @@ func (r *UserRepository) IsIPBanned(ctx context.Context, ipAddress string) (bool
|
||||||
`, ipAddress).Scan(&exists)
|
`, ipAddress).Scan(&exists)
|
||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Account Lifecycle: Deactivate, Delete, Destroy
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// DeactivateUser sets user status to deactivated, preserves all data
|
||||||
|
func (r *UserRepository) DeactivateUser(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE public.users
|
||||||
|
SET status = 'deactivated', updated_at = NOW()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactivateUser sets a deactivated user back to active
|
||||||
|
func (r *UserRepository) ReactivateUser(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE public.users
|
||||||
|
SET status = 'active', deleted_at = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $1::uuid AND status IN ('deactivated', 'pending_deletion')
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleDeletion marks account for deletion after 14 days
|
||||||
|
func (r *UserRepository) ScheduleDeletion(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE public.users
|
||||||
|
SET status = 'pending_deletion', deleted_at = NOW() + INTERVAL '14 days', updated_at = NOW()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelDeletion reverts a pending deletion back to active
|
||||||
|
func (r *UserRepository) CancelDeletion(ctx context.Context, userID string) error {
|
||||||
|
return r.ReactivateUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccountsPendingPurge returns user IDs whose deletion grace period has expired
|
||||||
|
func (r *UserRepository) GetAccountsPendingPurge(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id::text FROM public.users
|
||||||
|
WHERE status = 'pending_deletion' AND deleted_at IS NOT NULL AND deleted_at <= NOW()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CascadePurgeUser permanently deletes ALL user data from every table. Irreversible.
|
||||||
|
func (r *UserRepository) CascadePurgeUser(ctx context.Context, userID string) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Order matters: delete from leaf tables first, then parent tables
|
||||||
|
purgeQueries := []string{
|
||||||
|
// Post-related (leaf tables first)
|
||||||
|
`DELETE FROM public.post_reactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_likes WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_saves WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_mentions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_interactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_metrics WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_hashtags WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.post_categories WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
// Also remove this user's likes/saves/reactions on OTHER people's posts
|
||||||
|
`DELETE FROM public.post_reactions WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.post_likes WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.post_saves WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.post_interactions WHERE user_id = $1::uuid`,
|
||||||
|
// Beacon votes
|
||||||
|
`DELETE FROM public.beacon_votes WHERE user_id = $1::uuid`,
|
||||||
|
// Comments on user's posts
|
||||||
|
`DELETE FROM public.comments WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
|
||||||
|
// User's own comments
|
||||||
|
`DELETE FROM public.comments WHERE author_id = $1::uuid`,
|
||||||
|
// Feed engagement
|
||||||
|
`DELETE FROM public.feed_engagement WHERE user_id = $1::uuid`,
|
||||||
|
// Posts themselves
|
||||||
|
`DELETE FROM public.posts WHERE author_id = $1::uuid`,
|
||||||
|
// Messaging / E2EE
|
||||||
|
`DELETE FROM public.secure_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.encrypted_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.e2ee_session_state WHERE session_id IN (SELECT id FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid)`,
|
||||||
|
`DELETE FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid`,
|
||||||
|
`DELETE FROM public.encrypted_conversations WHERE participant_a = $1::uuid OR participant_b = $1::uuid`,
|
||||||
|
// Signal keys
|
||||||
|
`DELETE FROM public.one_time_prekeys WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.signed_prekeys WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.signal_keys WHERE user_id = $1::uuid`,
|
||||||
|
// Social graph
|
||||||
|
`DELETE FROM public.circle_members WHERE user_id = $1::uuid OR member_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.follows WHERE follower_id = $1::uuid OR following_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.blocks WHERE blocker_id = $1::uuid OR blocked_id = $1::uuid`,
|
||||||
|
// Notifications
|
||||||
|
`DELETE FROM public.notifications WHERE user_id = $1::uuid OR actor_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.notification_preferences WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid`,
|
||||||
|
// Moderation & violations
|
||||||
|
`DELETE FROM public.moderation_flags WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.pending_moderation WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_violation_history WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_violations WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_appeals WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.content_strikes WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.reports WHERE reporter_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.abuse_logs WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_status_history WHERE user_id = $1::uuid`,
|
||||||
|
// Categories & hashtags
|
||||||
|
`DELETE FROM public.user_category_preferences WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_category_settings WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.hashtag_follows WHERE user_id = $1::uuid`,
|
||||||
|
// Backup & recovery
|
||||||
|
`DELETE FROM public.recovery_shard_submissions WHERE session_id IN (SELECT id FROM public.recovery_sessions WHERE user_id = $1::uuid)`,
|
||||||
|
`DELETE FROM public.recovery_sessions WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.recovery_guardians WHERE user_id = $1::uuid OR guardian_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.cloud_backups WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.backup_preferences WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.sync_codes WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_devices WHERE user_id = $1::uuid`,
|
||||||
|
// Auth & tokens
|
||||||
|
`DELETE FROM public.auth_tokens WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.refresh_tokens WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.verification_tokens WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.password_reset_tokens WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.webauthn_credentials WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.user_mfa_secrets WHERE user_id = $1::uuid`,
|
||||||
|
// Settings
|
||||||
|
`DELETE FROM public.user_settings WHERE user_id = $1::uuid`,
|
||||||
|
`DELETE FROM public.profile_privacy_settings WHERE user_id = $1::uuid`,
|
||||||
|
// Trust
|
||||||
|
`DELETE FROM public.trust_state WHERE user_id = $1::uuid`,
|
||||||
|
// Username claims
|
||||||
|
`DELETE FROM public.username_claim_requests WHERE user_id = $1::uuid`,
|
||||||
|
// Profile & user (last)
|
||||||
|
`DELETE FROM public.profiles WHERE id = $1::uuid`,
|
||||||
|
`DELETE FROM public.users WHERE id = $1::uuid`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range purgeQueries {
|
||||||
|
if _, err := tx.Exec(ctx, q, userID); err != nil {
|
||||||
|
return fmt.Errorf("purge query failed: %s: %w", q[:60], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,11 @@ func (s *EmailService) SendAccountRestoredEmail(toEmail, toName, reason string)
|
||||||
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
|
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendGenericEmail sends an email with pre-built HTML and text bodies
|
||||||
|
func (s *EmailService) SendGenericEmail(toEmail, toName, subject, htmlBody, textBody string) error {
|
||||||
|
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EmailService) AddSubscriber(email, name string) {
|
func (s *EmailService) AddSubscriber(email, name string) {
|
||||||
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
||||||
// Endpoint: POST /addressbooks/{id}/emails
|
// Endpoint: POST /addressbooks/{id}/emails
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,21 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
),
|
),
|
||||||
_buildEditTile(
|
_buildEditTile(
|
||||||
icon: Icons.pause_circle_outline,
|
icon: Icons.pause_circle_outline,
|
||||||
title: 'Deactivation',
|
title: 'Deactivate Account',
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
onTap: () {},
|
onTap: () => _showDeactivateDialog(),
|
||||||
|
),
|
||||||
|
_buildEditTile(
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
title: 'Delete Account',
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
onTap: () => _showDeleteDialog(),
|
||||||
|
),
|
||||||
|
_buildEditTile(
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
title: 'Immediate Destroy',
|
||||||
|
color: Colors.red.shade800,
|
||||||
|
onTap: () => _showSuperDeleteDialog(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -672,6 +684,196 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Account Lifecycle Dialogs ---
|
||||||
|
|
||||||
|
void _showDeactivateDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.pause_circle_outline, color: Colors.orange, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Deactivate Account'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Your account will be hidden and you will be logged out. '
|
||||||
|
'All your data — posts, messages, connections — will be preserved indefinitely.\n\n'
|
||||||
|
'You can reactivate at any time simply by logging back in.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
await _performDeactivation();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.orange),
|
||||||
|
child: const Text('Deactivate'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Delete Account'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Your account will be deactivated immediately and permanently deleted after 14 days.\n\n'
|
||||||
|
'During those 14 days, you can cancel the deletion by logging back in. '
|
||||||
|
'After that, ALL data will be irreversibly destroyed — posts, messages, encryption keys, profile, everything.\n\n'
|
||||||
|
'Are you sure you want to proceed?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
await _performDeletion();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Delete My Account'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuperDeleteDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(child: Text('Immediate Destroy', style: TextStyle(color: Colors.red))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: const [
|
||||||
|
Text(
|
||||||
|
'THIS IS IRREVERSIBLE.',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'This will send a confirmation email to your registered address. '
|
||||||
|
'When you click the link in that email, your account and ALL associated data '
|
||||||
|
'will be permanently and immediately destroyed.\n\n'
|
||||||
|
'There is NO recovery. NO backup. NO undo.\n\n'
|
||||||
|
'Your posts, messages, encryption keys, profile, followers, and handle '
|
||||||
|
'will all be erased forever.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
await _performSuperDelete();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
backgroundColor: Colors.red.shade800,
|
||||||
|
),
|
||||||
|
child: const Text('Send Destroy Confirmation Email'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performDeactivation() async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
await api.callGoApi('/account/deactivate', method: 'POST');
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Account deactivated. Log back in anytime to reactivate.')),
|
||||||
|
);
|
||||||
|
await _signOut();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to deactivate: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performDeletion() async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
await api.callGoApi('/account', method: 'DELETE');
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Account scheduled for deletion in 14 days. Log back in to cancel.')),
|
||||||
|
);
|
||||||
|
await _signOut();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to schedule deletion: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSuperDelete() async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
await api.callGoApi('/account/destroy', method: 'POST');
|
||||||
|
if (!mounted) return;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Confirmation Email Sent'),
|
||||||
|
content: const Text(
|
||||||
|
'A confirmation email has been sent to your registered address.\n\n'
|
||||||
|
'You MUST click the link in that email to complete the destruction. '
|
||||||
|
'If you did not mean to do this, simply ignore the email — your account will not be affected.\n\n'
|
||||||
|
'The link expires in 1 hour.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to initiate destroy: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _signOut() async {
|
Future<void> _signOut() async {
|
||||||
final authService = ref.read(authServiceProvider);
|
final authService = ref.read(authServiceProvider);
|
||||||
await NotificationService.instance.removeToken();
|
await NotificationService.instance.removeToken();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue