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,145 @@
package persistence
import (
"RenewCMS/internal/domain/author"
domain "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
emptySocialLinks = "[]"
sqliteDialect = "sqlite"
)
var contentionMarkers = []string{
"database is locked",
"sqlite_busy",
"deadlock",
"could not serialize",
}
type AccountEraser struct {
db *gorm.DB
}
var _ domain.Eraser = &AccountEraser{}
func NewAccountEraser(db *gorm.DB) *AccountEraser {
return &AccountEraser{db}
}
func (e *AccountEraser) Erase(erasure domain.Erasure) error {
err := e.db.Transaction(func(tx *gorm.DB) error {
if err := e.recheckHolders(tx, erasure); err != nil {
return err
}
if err := tx.Where("user_id = ?", erasure.UserID).Delete(&entity.APIKey{}).Error; err != nil {
return err
}
ownAuthorID, err := e.releaseAuthor(tx, erasure.UserID)
if err != nil {
return err
}
if erasure.AvatarImageID != nil {
if err := tx.Delete(&entity.Image{}, *erasure.AvatarImageID).Error; err != nil {
return err
}
}
if err := NewAuditEventRepository(tx).AnonymiseUser(erasure.UserID, ownAuthorID, domain.DeletedUserName); err != nil {
return err
}
result := tx.Delete(&entity.User{}, erasure.UserID)
return deleteOrNotFound(result, domain.ErrUserNotFound)
})
if err != nil && len(erasure.GuardRoleIDs) > 0 && isContention(err) {
return domain.ErrAccountNotDeletable
}
return err
}
func isContention(err error) bool {
message := strings.ToLower(err.Error())
for _, marker := range contentionMarkers {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func (e *AccountEraser) recheckHolders(tx *gorm.DB, erasure domain.Erasure) error {
if len(erasure.GuardRoleIDs) == 0 {
return nil
}
if err := lockRoles(tx, erasure.GuardRoleIDs); err != nil {
return err
}
var holders []entity.User
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
Where("role_id IN ? AND is_verified = ? AND id <> ?", erasure.GuardRoleIDs, true, erasure.UserID).
Order("id ASC").
Find(&holders).Error
if err != nil {
return err
}
if len(holders) == 0 {
return domain.ErrAccountNotDeletable
}
return nil
}
func lockRoles(tx *gorm.DB, roleIDs []uint32) error {
if tx.Dialector.Name() == sqliteDialect {
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("id", gorm.Expr("id")).Error
}
var locked []entity.Role
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", roleIDs).Order("id ASC").Find(&locked).Error
}
func (e *AccountEraser) releaseAuthor(tx *gorm.DB, userID uint32) (*uint32, error) {
var linked entity.Author
found := tx.Where("user_id = ?", userID).Limit(1).Find(&linked)
if found.Error != nil {
return nil, found.Error
}
if found.RowsAffected == 0 {
return nil, nil
}
ownID := linked.ID
var credits int64
if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", linked.ID).Count(&credits).Error; err != nil {
return nil, err
}
if credits == 0 {
return &ownID, tx.Delete(&entity.Author{}, linked.ID).Error
}
slug, err := e.freeDeletedSlug(tx, linked.ID)
if err != nil {
return nil, err
}
return &ownID, tx.Model(&entity.Author{}).Where("id = ?", linked.ID).Updates(map[string]any{
"name": author.DeletedName,
"slug": slug,
"bio": "",
"avatar_id": nil,
"website": nil,
"social_links": emptySocialLinks,
"user_id": nil,
}).Error
}
func (e *AccountEraser) freeDeletedSlug(tx *gorm.DB, authorID uint32) (string, error) {
for attempt := 1; ; attempt++ {
candidate := author.DeletedSlugAttempt(authorID, attempt)
var taken int64
err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", candidate, authorID).Count(&taken).Error
if err != nil || taken == 0 {
return candidate, err
}
}
}
@@ -583,3 +583,18 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
}
var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) ListForAccount(creatorID uint32, authorID *uint32) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{})
if authorID == nil {
query = query.Where("created_by = ?", creatorID)
} else {
credited := a.db.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", *authorID)
query = query.Where("created_by = ? OR (is_online = ? AND id IN (?))", creatorID, true, credited)
}
var entityArticles []entity.Article
if err := query.Order("id asc").Find(&entityArticles).Error; err != nil {
return nil, err
}
return a.withAuthors(a.db, toDomainArticles(entityArticles))
}
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"strconv"
"strings"
"time"
@@ -233,3 +234,38 @@ func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, e
}
return removed, nil
}
const (
erasedParams = "{}"
erasedChanges = "[]"
authorActions = "author.%"
)
func (a *AuditEventRepository) AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error {
userTargetID := strconv.FormatUint(uint64(userID), 10)
involved := "(actor_type = ? AND actor_id = ?) OR (target_type = ? AND target_id = ?)"
asActor := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND actor_id = ?", string(audit.ActorUser), userID).
Updates(map[string]any{"actor_name": name, "ip": nil, "user_agent": nil})
if asActor.Error != nil {
return asActor.Error
}
asTarget := a.db.Model(&entity.AuditEvent{}).
Where("target_type = ? AND target_id = ?", audit.TargetUser, userTargetID).
Update("target_name", name)
if asTarget.Error != nil {
return asTarget.Error
}
if ownAuthorID != nil {
ownProfile := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND actor_id = ? AND action LIKE ? AND target_type = ? AND target_id = ?",
string(audit.ActorUser), userID, authorActions, audit.TargetAuthor, strconv.FormatUint(uint64(*ownAuthorID), 10)).
Update("target_name", name)
if ownProfile.Error != nil {
return ownProfile.Error
}
}
return a.db.Model(&entity.AuditEvent{}).
Where(involved, string(audit.ActorUser), userID, audit.TargetUser, userTargetID).
Updates(map[string]any{"params": erasedParams, "changes": erasedChanges}).Error
}
@@ -154,3 +154,11 @@ func entriesToDomain(models []entity.Entry) ([]domain.Entry, error) {
}
return items, nil
}
func (e *EntryRepository) ListByCreator(creatorID uint32) ([]domain.Entry, error) {
var models []entity.Entry
if err := e.db.Where("created_by = ?", creatorID).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return entriesToDomain(models)
}
@@ -160,3 +160,11 @@ func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) err
}
var _ domain.Repository = &ImageRepository{}
func (i ImageRepository) ListByUploader(userID uint32) ([]domain.Image, error) {
var models []entity.Image
if err := i.db.Where("uploaded_by_id = ?", userID).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
@@ -242,6 +242,80 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil
}
func (u *UserRepository) IsEmailInUse(email string, exceptUserId uint32) (bool, error) {
return isEmailInUse(u.db, email, exceptUserId)
}
func isEmailInUse(db *gorm.DB, email string, exceptUserId uint32) (bool, error) {
var holders int64
err := db.Model(&entity.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, exceptUserId).Count(&holders).Error
return holders > 0, err
}
func (u *UserRepository) SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (domainUser.User, error) {
return u.updatePendingEmailChange(userId, map[string]any{
"pending_email": mappers.DomainToNullableString(pendingEmail),
"email_change_code": mappers.DomainToNullableString(code),
"email_change_expiration": expiration,
})
}
func (u *UserRepository) ClearPendingEmailChange(userId uint32) (domainUser.User, error) {
return u.updatePendingEmailChange(userId, clearedEmailChange())
}
func clearedEmailChange() map[string]any {
return map[string]any{
"pending_email": nil,
"email_change_code": nil,
"email_change_expiration": time.Time{},
}
}
func (u *UserRepository) updatePendingEmailChange(userId uint32, columns map[string]any) (domainUser.User, error) {
result := u.db.Model(&entity.User{}).Where("id = ?", userId).Updates(columns)
if result.Error != nil {
return domainUser.User{}, result.Error
}
return u.Get(userId)
}
func (u *UserRepository) ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (domainUser.User, error) {
taken, err := isEmailInUse(u.db, pendingEmail, userId)
if err != nil {
return domainUser.User{}, err
}
columns := clearedEmailChange()
if !taken {
columns["email"] = pendingEmail
}
result := pendingChangeOf(u.db, userId, pendingEmail, code).Where("email_change_expiration > ?", now).Updates(columns)
if result.Error != nil && isUniqueViolation(result.Error) {
return domainUser.User{}, u.refuseTakenAddress(userId, pendingEmail, code)
}
if result.Error != nil {
return domainUser.User{}, result.Error
}
if result.RowsAffected == 0 {
return domainUser.User{}, domainUser.ErrInvalidEmailChangeCode
}
if taken {
return domainUser.User{}, domainUser.ErrEmailUnavailable
}
return u.Get(userId)
}
func (u *UserRepository) refuseTakenAddress(userId uint32, pendingEmail string, code string) error {
if err := pendingChangeOf(u.db, userId, pendingEmail, code).Updates(clearedEmailChange()).Error; err != nil {
return err
}
return domainUser.ErrEmailUnavailable
}
func pendingChangeOf(db *gorm.DB, userId uint32, pendingEmail string, code string) *gorm.DB {
return db.Model(&entity.User{}).Where("id = ? AND pending_email = ? AND email_change_code = ?", userId, pendingEmail, code)
}
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)