feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
1 parent bb191c389a
commit 10a66cd979
123 files changed
+10789 -76

No files matched your search

@@ -0,0 +1,199 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
"log"
"slices"
"time"
)
const accountDeletedTemplateName = "account-deleted"
type MailAccountDeletedData struct {
Host string
}
type sessionRevoker interface {
Revoke(id string, expiresAt time.Time) error
}
type DeleteOwnAccountUseCase struct {
users user.Repository
roles role.Repository
hasher hash.Hasher
eraser user.Eraser
sessions sessionRevoker
mailRepository mail.Repository
dispatcher mail.Dispatcher
publicURL string
recorder audit.Recorder
authors author.Repository
images image.Repository
usage *GetMediaUsageUseCase
avatars mediaRemover
}
type DeleteOwnAccountCommand struct {
UserID uint32
CurrentPassword string
SessionID string
SessionExpiresAt time.Time
}
func NewDeleteOwnAccountUseCase(users user.Repository, roles role.Repository, hasher hash.Hasher, eraser user.Eraser, sessions sessionRevoker, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, recorder audit.Recorder, authors author.Repository, images image.Repository, usage *GetMediaUsageUseCase, storage image.Storage, variantFiles image.VariantFileRepository) *DeleteOwnAccountUseCase {
return &DeleteOwnAccountUseCase{users, roles, hasher, eraser, sessions, mailRepository, dispatcher, publicURL, recorder, authors, images, usage, newMediaRemover(images, storage, variantFiles)}
}
func (u *DeleteOwnAccountUseCase) Delete(ctx context.Context, command DeleteOwnAccountCommand) error {
account, err := u.users.Get(command.UserID)
if err != nil {
return err
}
event := audit.NewEvent(audit.ActionUserDeleteAccount, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
guard, err := u.check(account, command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
avatar, err := u.removableAvatar(account)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
erasure := user.Erasure{UserID: account.ID, GuardRoleIDs: guard}
if avatar != nil {
erasure.AvatarImageID = &avatar.ID
}
if err := u.eraser.Erase(erasure); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.discardAvatar(avatar)
u.revoke(command)
u.notify(account)
u.recordSuccess(ctx, account)
return nil
}
func (u *DeleteOwnAccountUseCase) check(account user.User, command DeleteOwnAccountCommand) ([]uint32, error) {
if u.hasher.Compare(account.Password, command.CurrentPassword) != nil {
return nil, user.ErrWrongPassword
}
guard, label, err := u.guardedRoles(account)
if err != nil || len(guard) == 0 {
return nil, err
}
hasOther, err := u.hasOtherHolder(account.ID, guard)
if err != nil {
return nil, err
}
if !hasOther {
return nil, user.LastAdminError{Role: label}
}
return guard, nil
}
func (u *DeleteOwnAccountUseCase) guardedRoles(account user.User) ([]uint32, string, error) {
owner, err := builtInRole(u.roles, role.OwnerName)
if err != nil {
return nil, "", err
}
if account.RoleID == owner.ID {
return []uint32{owner.ID}, user.LastRoleOwner, nil
}
admin, err := builtInRole(u.roles, role.AdminName)
if err != nil {
return nil, "", err
}
if account.RoleID == admin.ID {
return []uint32{owner.ID, admin.ID}, user.LastRoleAdmin, nil
}
return nil, "", nil
}
func (u *DeleteOwnAccountUseCase) hasOtherHolder(exceptUserID uint32, roleIDs []uint32) (bool, error) {
everyone, err := u.users.GetAll()
if err != nil {
return false, err
}
for _, candidate := range everyone {
if candidate.ID != exceptUserID && candidate.IsVerified && slices.Contains(roleIDs, candidate.RoleID) {
return true, nil
}
}
return false, nil
}
func (u *DeleteOwnAccountUseCase) removableAvatar(account user.User) (*image.Image, error) {
own, err := u.authors.GetByUserID(account.ID)
if errors.Is(err, author.ErrAuthorNotFound) || err == nil && own.AvatarID == nil {
return nil, nil
}
if err != nil {
return nil, err
}
avatar, err := u.images.Get(*own.AvatarID)
if errors.Is(err, image.ErrImageNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
usages, err := u.usage.Usage(avatar.ID)
if err != nil {
return nil, err
}
for _, found := range usages {
if found.Type != UsageTypeAuthor || found.ID != own.ID {
return nil, nil
}
}
return &avatar, nil
}
func (u *DeleteOwnAccountUseCase) discardAvatar(avatar *image.Image) {
if avatar == nil {
return
}
if err := u.avatars.discardFiles(*avatar); err != nil {
log.Println("error when deleting the stored avatar of a deleted account:", err)
}
}
func (u *DeleteOwnAccountUseCase) revoke(command DeleteOwnAccountCommand) {
if command.SessionID == "" {
return
}
if err := u.sessions.Revoke(command.SessionID, command.SessionExpiresAt); err != nil {
log.Println("error when revoking the session of a deleted account:", err)
}
}
func (u *DeleteOwnAccountUseCase) notify(account user.User) {
address := account.Email
u.dispatcher.Dispatch(func() {
if err := u.mailRepository.Send(address, accountDeletedTemplateName, MailAccountDeletedData{Host: u.publicURL}); err != nil {
log.Println("error when Send-ing the account deleted mail:", err)
}
})
}
func (u *DeleteOwnAccountUseCase) recordSuccess(ctx context.Context, account user.User) {
event := audit.NewEvent(audit.ActionUserDeleteAccount, audit.CategoryUsers)
id := account.ID
event.Actor = audit.Actor{Type: audit.ActorUser, ID: &id, Name: user.DeletedUserName}
event.Target = audit.NewTarget(audit.TargetUser, account.ID, user.DeletedUserName)
event.Security = true
u.recorder.Record(ctx, event)
}