feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user