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,34 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"context"
)
type CancelEmailChangeUseCase struct {
userRepository user.Repository
recorder audit.Recorder
}
func NewCancelEmailChangeUseCase(userRepository user.Repository, recorder audit.Recorder) *CancelEmailChangeUseCase {
return &CancelEmailChangeUseCase{userRepository, recorder}
}
func (u *CancelEmailChangeUseCase) CancelEmailChange(ctx context.Context, userId uint32) error {
account, err := u.userRepository.Get(userId)
if err != nil {
return err
}
if account.PendingEmail == "" {
return nil
}
event := audit.NewEvent(audit.ActionUserCancelEmail, audit.CategoryUsers)
event.Target = userTarget(account)
if _, err := u.userRepository.ClearPendingEmailChange(userId); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
@@ -0,0 +1,52 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type ConfirmEmailChangeUseCase struct {
userRepository user.Repository
hasher hash.Hasher
now func() time.Time
recorder audit.Recorder
}
func NewConfirmEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, now func() time.Time, recorder audit.Recorder) *ConfirmEmailChangeUseCase {
return &ConfirmEmailChangeUseCase{userRepository, hasher, now, recorder}
}
func (u *ConfirmEmailChangeUseCase) ConfirmEmailChange(ctx context.Context, userId uint32, code string) (string, error) {
account, err := u.userRepository.Get(userId)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserConfirmEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if !u.accepts(account, code) {
recordFailure(ctx, u.recorder, event, user.ErrInvalidEmailChangeCode)
return "", user.ErrInvalidEmailChangeCode
}
updated, err := u.userRepository.ConfirmEmailChange(userId, account.PendingEmail, account.EmailChangeCode, u.now())
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.recorder.Record(ctx, event)
return updated.Email, nil
}
func (u *ConfirmEmailChangeUseCase) accepts(account user.User, code string) bool {
if account.PendingEmail == "" || account.EmailChangeCode == "" {
return false
}
if u.now().After(account.EmailChangeExpiration) {
return false
}
return u.hasher.Compare(account.EmailChangeCode, code) == nil
}
@@ -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)
}
@@ -0,0 +1,356 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
)
const (
exportProfileFile = "profile.json"
exportAuthoredFile = "authored-content.json"
exportMediaFile = "uploaded-media.json"
exportActivityFile = "account-activity.json"
exportIndent = " "
exportFilenameDate = "2006-01-02"
exportFallbackName = "user"
)
type ExportUserDataUseCase struct {
users user.Repository
roles role.Repository
authors author.Repository
keys apikey.Repository
articles article.Repository
entries entry.Repository
contentTypes contenttype.Repository
images image.Repository
events audit.Repository
now func() time.Time
recorder audit.Recorder
slots chan struct{}
}
type UserDataArchive struct {
Filename string
Content []byte
}
type exportRole struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type exportAPIKey struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type exportProfile struct {
ID uint32 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
PendingEmail string `json:"pending_email"`
Role exportRole `json:"role"`
IsVerified bool `json:"is_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Author *author.Byline `json:"author"`
APIKeys []exportAPIKey `json:"api_keys"`
}
type exportArticle struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Slug string `json:"slug"`
SiteID uint32 `json:"site_id"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Authors []exportCredit `json:"authors"`
}
type exportCredit struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type exportEntry struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type exportAuthored struct {
Articles []exportArticle `json:"articles"`
Entries []exportEntry `json:"entries"`
}
type exportImage struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Hash string `json:"hash"`
UploadedAt time.Time `json:"uploaded_at"`
}
type exportMedia struct {
Images []exportImage `json:"images"`
}
type exportEvent struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Target audit.Target `json:"target"`
Outcome audit.Outcome `json:"outcome"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
}
type exportActivity struct {
Events []exportEvent `json:"events"`
}
func NewExportUserDataUseCase(users user.Repository, roles role.Repository, authors author.Repository, keys apikey.Repository, articles article.Repository, entries entry.Repository, contentTypes contenttype.Repository, images image.Repository, events audit.Repository, now func() time.Time, recorder audit.Recorder, maxConcurrent int) *ExportUserDataUseCase {
return &ExportUserDataUseCase{users, roles, authors, keys, articles, entries, contentTypes, images, events, now, recorder, make(chan struct{}, max(1, maxConcurrent))}
}
func (u *ExportUserDataUseCase) Export(ctx context.Context, userID uint32) (UserDataArchive, error) {
select {
case u.slots <- struct{}{}:
defer func() { <-u.slots }()
case <-ctx.Done():
return UserDataArchive{}, ctx.Err()
}
account, err := u.users.Get(userID)
if err != nil {
return UserDataArchive{}, err
}
profile, linked, err := u.profile(account)
if err != nil {
return UserDataArchive{}, err
}
authored, err := u.authored(account, linked)
if err != nil {
return UserDataArchive{}, err
}
media, err := u.media(account)
if err != nil {
return UserDataArchive{}, err
}
activity, err := u.activity(account, linked)
if err != nil {
return UserDataArchive{}, err
}
content, err := zipJSON([]namedDocument{
{exportProfileFile, profile},
{exportAuthoredFile, authored},
{exportMediaFile, media},
{exportActivityFile, activity},
})
if err != nil {
return UserDataArchive{}, err
}
event := audit.NewEvent(audit.ActionUserExportData, audit.CategoryUsers)
event.Target = userTarget(account)
u.recorder.Record(ctx, event)
return UserDataArchive{Filename: u.filename(account), Content: content}, nil
}
func (u *ExportUserDataUseCase) filename(account user.User) string {
return "renewcms-export-" + exportUsername(account.Username) + "-" + u.now().UTC().Format(exportFilenameDate) + ".zip"
}
func exportUsername(username string) string {
var reduced strings.Builder
for _, char := range strings.ToLower(username) {
if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' {
reduced.WriteRune(char)
} else if !strings.HasSuffix(reduced.String(), "-") {
reduced.WriteRune('-')
}
}
name := strings.Trim(reduced.String(), "-")
if name == "" {
return exportFallbackName
}
return name
}
func (u *ExportUserDataUseCase) profile(account user.User) (exportProfile, *author.Author, error) {
assigned, err := u.roles.Get(account.RoleID)
if err != nil {
return exportProfile{}, nil, err
}
linked, err := u.linkedAuthor(account.ID)
if err != nil {
return exportProfile{}, nil, err
}
keys, err := u.keys.ListByUser(account.ID)
if err != nil {
return exportProfile{}, nil, err
}
profile := exportProfile{
ID: account.ID, Username: account.Username, DisplayName: account.DisplayName, Email: account.Email,
PendingEmail: account.PendingEmail, Role: exportRole{ID: assigned.ID, Name: assigned.Name},
IsVerified: account.IsVerified, CreatedAt: account.CreatedAt, UpdatedAt: account.UpdatedAt,
APIKeys: make([]exportAPIKey, 0, len(keys)),
}
if linked != nil {
byline := linked.Byline()
profile.Author = &byline
}
for _, key := range keys {
profile.APIKeys = append(profile.APIKeys, exportAPIKey{ID: key.ID, Name: key.Name, Prefix: key.Prefix, CreatedAt: key.CreatedAt, LastUsedAt: key.LastUsedAt, RevokedAt: key.RevokedAt})
}
return profile, linked, nil
}
func (u *ExportUserDataUseCase) linkedAuthor(userID uint32) (*author.Author, error) {
linked, err := u.authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &linked, nil
}
func (u *ExportUserDataUseCase) authored(account user.User, linked *author.Author) (exportAuthored, error) {
var authorID *uint32
if linked != nil {
authorID = &linked.ID
}
articles, err := u.articles.ListForAccount(account.ID, authorID)
if err != nil {
return exportAuthored{}, err
}
entries, err := u.entries.ListByCreator(account.ID)
if err != nil {
return exportAuthored{}, err
}
keys, err := u.contentTypeKeys()
if err != nil {
return exportAuthored{}, err
}
authored := exportAuthored{Articles: make([]exportArticle, 0, len(articles)), Entries: make([]exportEntry, 0, len(entries))}
for _, item := range articles {
credits := make([]exportCredit, 0, len(item.Authors))
for _, byline := range item.Authors {
credits = append(credits, exportCredit{byline.ID, byline.Name, byline.Slug})
}
authored.Articles = append(authored.Articles, exportArticle{item.ID, item.Title, item.Body, item.Slug, item.SiteID, item.IsOnline, item.CreatedAt, item.UpdatedAt, credits})
}
for _, item := range entries {
key, known := keys[item.ContentTypeID]
if !known {
key = strconv.FormatUint(uint64(item.ContentTypeID), 10)
}
authored.Entries = append(authored.Entries, exportEntry{item.ID, key, item.Data, item.CreatedAt, item.UpdatedAt})
}
return authored, nil
}
func (u *ExportUserDataUseCase) contentTypeKeys() (map[uint32]string, error) {
types, err := u.contentTypes.List()
if err != nil {
return nil, err
}
keys := make(map[uint32]string, len(types))
for _, contentType := range types {
keys[contentType.ID] = contentType.Key
}
return keys, nil
}
func (u *ExportUserDataUseCase) media(account user.User) (exportMedia, error) {
images, err := u.images.ListByUploader(account.ID)
if err != nil {
return exportMedia{}, err
}
media := exportMedia{Images: make([]exportImage, 0, len(images))}
for _, item := range images {
media.Images = append(media.Images, exportImage{item.ID, item.Filename, item.ContentType, item.Size, item.SHA256, item.CreatedAt})
}
return media, nil
}
func (u *ExportUserDataUseCase) activity(account user.User, linked *author.Author) (exportActivity, error) {
id := account.ID
var ownAuthorID *uint32
if linked != nil {
ownAuthorID = &linked.ID
}
page, err := u.events.List(audit.Filter{Actor: &audit.ActorRef{Type: audit.ActorUser, ID: &id}})
if err != nil {
return exportActivity{}, err
}
activity := exportActivity{Events: make([]exportEvent, 0, len(page.Items))}
for _, item := range page.Items {
if item.ConcernsOtherPerson(account.ID, ownAuthorID) {
continue
}
params := item.Params
if params == nil {
params = map[string]any{}
}
activity.Events = append(activity.Events, exportEvent{item.ID, item.CreatedAt, item.Action, item.Category, item.Target, item.Outcome, item.Security, item.Source, item.IP, item.UserAgent, params})
}
return activity, nil
}
type namedDocument struct {
name string
value any
}
func zipJSON(documents []namedDocument) ([]byte, error) {
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
for _, document := range documents {
file, err := archive.Create(document.name)
if err != nil {
return nil, err
}
encoder := json.NewEncoder(file)
encoder.SetIndent("", exportIndent)
if err := encoder.Encode(document.value); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -0,0 +1,60 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"strings"
"time"
)
type RequestEmailChangeUseCase struct {
userRepository user.Repository
hasher hash.Hasher
issuer emailChangeIssuer
recorder audit.Recorder
}
type RequestEmailChangeCommand struct {
UserID uint32
NewEmail string
CurrentPassword string
}
func NewRequestEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, now func() time.Time, recorder audit.Recorder) *RequestEmailChangeUseCase {
return &RequestEmailChangeUseCase{userRepository, hasher, emailChangeIssuer{userRepository, hasher, mailRepository, dispatcher, publicURL, now}, recorder}
}
func (u *RequestEmailChangeUseCase) RequestEmailChange(ctx context.Context, command RequestEmailChangeCommand) (string, error) {
account, err := u.userRepository.Get(command.UserID)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserRequestEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if err := u.refuse(account, command); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
if _, err := u.issuer.issue(account, command.NewEmail); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.issuer.sendNotice(account, command.NewEmail)
u.recorder.Record(ctx, event)
return command.NewEmail, nil
}
func (u *RequestEmailChangeUseCase) refuse(account user.User, command RequestEmailChangeCommand) error {
if u.hasher.Compare(account.Password, command.CurrentPassword) != nil {
return user.ErrWrongPassword
}
if strings.EqualFold(strings.TrimSpace(command.NewEmail), account.Email) {
return user.ErrSameEmail
}
return nil
}
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type ResendEmailChangeUseCase struct {
userRepository user.Repository
issuer emailChangeIssuer
recorder audit.Recorder
}
func NewResendEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, now func() time.Time, recorder audit.Recorder) *ResendEmailChangeUseCase {
return &ResendEmailChangeUseCase{userRepository, emailChangeIssuer{userRepository, hasher, mailRepository, dispatcher, publicURL, now}, recorder}
}
func (u *ResendEmailChangeUseCase) ResendEmailChange(ctx context.Context, userId uint32) (string, error) {
account, err := u.userRepository.Get(userId)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserResendEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if account.PendingEmail == "" {
recordFailure(ctx, u.recorder, event, user.ErrNoPendingEmailChange)
return "", user.ErrNoPendingEmailChange
}
if _, err := u.issuer.issue(account, account.PendingEmail); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.recorder.Record(ctx, event)
return account.PendingEmail, nil
}
@@ -44,6 +44,14 @@ var failureReasons = []failureReason{
{user.ErrVerificationCodeExpired, "The verification code has expired."},
{user.ErrInvalidDisplayName, "That display name is too long."},
{user.ErrUsernameTaken, "That username is already taken."},
{user.ErrWrongPassword, audit.ReasonWrongPassword},
{user.ErrLastOwner, "Last Owner."},
{user.ErrLastAdmin, "Last administrator."},
{user.ErrAccountNotDeletable, "The account can't be deleted right now."},
{user.ErrSameEmail, "The new address is already the account's email address."},
{user.ErrNoPendingEmailChange, "No email change is pending."},
{user.ErrInvalidEmailChangeCode, "The email change link is not valid."},
{user.ErrEmailUnavailable, "The new address can't be used for this account."},
{user.ErrPasswordTooLong, "The password is longer than 72 bytes."},
{invite.ErrEmailHasAccount, "That email already has an account."},
{invite.ErrPendingInvite, "An invitation is already pending for that email."},
@@ -0,0 +1,81 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"log"
"time"
"github.com/google/uuid"
)
const (
verifyNewEmailTemplateName = "verify-new-email"
emailChangeNoticeTemplateName = "email-change-notice"
)
type MailVerifyNewEmailData struct {
Host string
Username string
VerificationCode string
}
type MailEmailChangeNoticeData struct {
Host string
Username string
NewEmail string
}
type emailChangeIssuer struct {
userRepository user.Repository
hasher hash.Hasher
mailRepository mail.Repository
dispatcher mail.Dispatcher
publicURL string
now func() time.Time
}
func (i emailChangeIssuer) issue(account user.User, pendingEmail string) (user.User, error) {
rawCode := uuid.NewString()
hashedCode, err := i.hasher.Hash(rawCode)
if err != nil {
return user.User{}, err
}
updated, err := i.userRepository.SetPendingEmailChange(account.ID, pendingEmail, hashedCode, i.now().Add(user.EmailChangeLifetime))
if err != nil {
return user.User{}, err
}
i.dispatcher.Dispatch(func() { i.sendVerification(account, pendingEmail, rawCode) })
return updated, nil
}
func (i emailChangeIssuer) sendVerification(account user.User, pendingEmail string, rawCode string) {
taken, err := i.userRepository.IsEmailInUse(pendingEmail, account.ID)
if err != nil {
log.Println("error when checking whether the new email address is in use:", err)
return
}
if taken {
return
}
if err := i.mailRepository.Send(pendingEmail, verifyNewEmailTemplateName, MailVerifyNewEmailData{
Host: i.publicURL,
Username: account.Username,
VerificationCode: rawCode,
}); err != nil {
log.Println("error when Send-ing the new email verification mail:", err)
}
}
func (i emailChangeIssuer) sendNotice(account user.User, pendingEmail string) {
i.dispatcher.Dispatch(func() {
if err := i.mailRepository.Send(account.Email, emailChangeNoticeTemplateName, MailEmailChangeNoticeData{
Host: i.publicURL,
Username: account.Username,
NewEmail: pendingEmail,
}); err != nil {
log.Println("error when Send-ing the email change notice mail:", err)
}
})
}